babel-plugin-wallace 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/lib/index.js CHANGED
@@ -1,12 +1,46 @@
1
+ require('./polyfills')
2
+ const {config} = require('./config')
3
+ const {jsxContextClasses} = require('./jsx/contexts')
4
+ const {importChecker} = require('./import-checker')
1
5
 
2
- module.exports = () => {
6
+
7
+ module.exports = ({ types: t }) => {
3
8
  return {
4
9
  visitor: {
10
+ Program: {
11
+ enter(path, state) {
12
+ config.configure(state.opts)
13
+ },
14
+ exit(path) {
15
+ importChecker.addMissingImports(path, t)
16
+ }
17
+ },
18
+ ImportDeclaration(path) {
19
+ // Maybe use this to identify files with Component import so it's not imported twice?
20
+ // importChecker.hasComponentImport(path.hub.file.opts.filename)
21
+ },
22
+ /**
23
+ * This node will be removed by handling code, which means:
24
+ * - We only ever catch the root JSXElement here.
25
+ * - We need to be mindful if we decide to add more visitors.
26
+ */
5
27
  JSXElement(path) {
6
- var openingElement = path.node.openingElement;
7
- var tagName = openingElement.name.name;
8
- console.log('JSXElement', path.node)
9
- path.replaceWithSourceString("'I found JSX!")
28
+ const contextMatches = jsxContextClasses.map(cls => new cls(path)).filter(context => context.matches())
29
+ if (contextMatches.length == 1) {
30
+ contextMatches[0].handle()
31
+ }
32
+ if (contextMatches.length === 0) {
33
+ throw path.buildCodeFrameError("Found JSX in a place Wallace doesn't know how to handle.")
34
+ }
35
+ if (contextMatches.length > 1) {
36
+ const contextNames = contextMatches.map(cls => cls.name)
37
+ throw path.buildCodeFrameError(
38
+ `JSX matches multiple contexts (${contextNames}). Please report this to wallace!`
39
+ )
40
+ }
41
+ // TODO: can I just use path.hub?
42
+ const program = path.findParent((path) => path.type == 'Program')
43
+ importChecker.requiresComponentImport(program.hub.file.opts.filename)
10
44
  }
11
45
  }
12
46
  }
@@ -0,0 +1,40 @@
1
+ const {stripHtml, preprocessHTML} = require('../utils/dom')
2
+ const {escapeSingleQuotes} = require('../utils/misc')
3
+
4
+ /**
5
+ * Build the component's DOM as the JSX is parsed, tracking the address of the current
6
+ * node.
7
+ */
8
+ class ComponentDOM {
9
+ constructor() {
10
+ // The address of the current node processed e.g. [1, 3, 0]
11
+ this._nodeTreeAddress = []
12
+ this._rootElement = undefined
13
+ }
14
+ attach (element) {
15
+ if (this._rootElement) {
16
+ const relativePath = this._nodeTreeAddress.slice(0, -1)
17
+ const parentNode = relativePath.reduce((acc, index) => acc.childNodes[index], this._rootElement)
18
+ parentNode.appendChild(element)
19
+ } else {
20
+ this._rootElement = element
21
+ }
22
+ }
23
+ getHtmlString() {
24
+ return escapeSingleQuotes(stripHtml(preprocessHTML(this._rootElement.outerHTML)))
25
+ }
26
+ getCurrentAddress () {
27
+ return this._nodeTreeAddress.slice()
28
+ }
29
+ push(i) {
30
+ if (i !== undefined) {
31
+ this._nodeTreeAddress.push(i)
32
+ }
33
+ }
34
+ pop() {
35
+ this._nodeTreeAddress.pop()
36
+ }
37
+ }
38
+
39
+
40
+ module.exports = {ComponentDOM}
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Classes for processing JSX in allowed contexts.
3
+ * Each context is handled by a different subclass.
4
+ * Each class is responsible for modifying the surrounding code.
5
+ */
6
+ const { convertJSX } = require('../convert.js')
7
+ const { insertStatementsAfter } = require('../utils/babel')
8
+
9
+
10
+ class BaseJSXContextHandler {
11
+ constructor(path) {
12
+ this.path = path
13
+ }
14
+ matches() {
15
+ // TODO create internal wallace error.
16
+ throw new Error('Not implemented')
17
+ }
18
+ handle() {
19
+ throw new Error('Not implemented')
20
+ }
21
+ }
22
+
23
+
24
+ /**
25
+ * Handles JSX as direct assignment:
26
+ *
27
+ * const MyComponent = <div>...</div>
28
+ */
29
+ class JSXInDirectAssignment extends BaseJSXContextHandler {
30
+ matches() {
31
+ return this.path.parent?.type === 'VariableDeclarator'
32
+ }
33
+ handle() {
34
+ const nodeToInsertAfter = this.path.parentPath.parentPath
35
+ const componentName = this.path.parentPath.node.id.name
36
+ const statements = convertJSX(this.path, componentName)
37
+ this.path.replaceWithSourceString(`Component.extend({})`)
38
+ insertStatementsAfter(nodeToInsertAfter, statements)
39
+ }
40
+ }
41
+
42
+
43
+ /**
44
+ * Handles JSXElement in call to Component.extend:
45
+ *
46
+ * const MyComponent = Component.extend(JSXElement)
47
+ * const MyComponent = Component.extend({...}, JSXElement)
48
+ *
49
+ */
50
+ class JSXInComponentDefineCallArg extends BaseJSXContextHandler {
51
+ matches() {
52
+ const parent = this.path.parent
53
+ if (
54
+ parent?.type == 'CallExpression' &&
55
+ parent.callee?.type == 'MemberExpression' &&
56
+ parent.callee?.object?.name == 'Component' &&
57
+ parent.callee?.property?.name == 'extend'
58
+ ) {
59
+ return true
60
+ }
61
+ return false
62
+ }
63
+ handle() {
64
+ const JSXElementPath = this.path
65
+ const CallExpressionPath = JSXElementPath.parentPath
66
+ const VariableDeclaratorPath = CallExpressionPath.parentPath
67
+ const nodeToInsertAfter = VariableDeclaratorPath.parentPath
68
+ const componentName = VariableDeclaratorPath.node.id.name
69
+
70
+ // TODO: change to extend({}, internalOpts)
71
+ const statements = convertJSX(this.path, componentName)
72
+ if (CallExpressionPath.node.arguments.length == 1) {
73
+ JSXElementPath.replaceWithSourceString('{}')
74
+ } else {
75
+ JSXElementPath.remove()
76
+ }
77
+ insertStatementsAfter(nodeToInsertAfter, statements)
78
+ }
79
+ }
80
+
81
+
82
+ /**
83
+ * Handles JSX in a class definition:
84
+ *
85
+ * class Foo extends Component {
86
+ * html = <div>...</div>
87
+ * }
88
+ */
89
+ class JSXInClassDefinition extends BaseJSXContextHandler {
90
+ matches() {
91
+ const parent = this.path.parent
92
+ return (
93
+ parent?.type == 'CallExpression' &&
94
+ parent.callee?.type == "Identifier" &&
95
+ parent.callee.name == "_defineProperty" &&
96
+ parent.arguments[1]?.value == "html"
97
+ )
98
+ }
99
+ handle() {
100
+ const JSXElementPath = this.path
101
+ const CallExpressionPath = JSXElementPath.parentPath
102
+ const ExpressionStatement = CallExpressionPath.parentPath
103
+ const BlockStatement = ExpressionStatement.parentPath
104
+ const FunctionDeclaration = BlockStatement.parentPath
105
+ const componentName = FunctionDeclaration.node.id.name
106
+ const nodeToInsertAfter = FunctionDeclaration.parentPath.parentPath.parentPath.parentPath.parentPath
107
+ // console.log(ExpressionStatement.node)
108
+ ExpressionStatement.remove()
109
+ const statements = convertJSX(this.path, componentName)
110
+ insertStatementsAfter(nodeToInsertAfter, statements)
111
+ }
112
+ }
113
+
114
+
115
+ module.exports = {
116
+ jsxContextClasses: [
117
+ JSXInDirectAssignment,
118
+ JSXInComponentDefineCallArg,
119
+ JSXInClassDefinition,
120
+ ]
121
+ }
@@ -0,0 +1,214 @@
1
+ const {JSDOM} = require( 'jsdom')
2
+ const {readCode} = require('../utils/babel')
3
+ const {config} = require('../config')
4
+ const {NodeData} = require('../definitions/node_data')
5
+ const {addInlineWatches} = require('../parse/inline_directives')
6
+
7
+ const dom = new JSDOM('<!DOCTYPE html>')
8
+ const doc = dom.window.document
9
+ const splitter = "-----"
10
+ const directives = config.directives
11
+ const isCapitalized = word => word[0] === word[0].toUpperCase()
12
+ const onlyHas = (arr, item) => arr.length === 0 || arr.length === 1 && arr[0] === item
13
+
14
+
15
+ class BaseConverter {
16
+ constructor(componentName, path, astNode, parentNodeData, nodeTreeAddress) {
17
+ this.componentName = componentName
18
+ this.path = path
19
+ this.astNode = astNode
20
+ this.parentNodeData = parentNodeData
21
+ this.nodeTreeAddress = nodeTreeAddress
22
+ this.nodeData = new NodeData(componentName, path, parentNodeData, nodeTreeAddress)
23
+ this.element = undefined
24
+ }
25
+ readCode(astNode) {
26
+ return readCode(this.path, astNode)
27
+ }
28
+ }
29
+
30
+
31
+ class JSXTextConverter extends BaseConverter {
32
+ convert() {
33
+ // expressions in text become JSXExpressionContainers, so there is never dynamic
34
+ // data inside JSText.
35
+ // Except that's currently not true due to how we squash. TODO: fix this.
36
+ const text = this.astNode.value
37
+ this.element = doc.createTextNode(text)
38
+ // console.log(text)
39
+ addInlineWatches(this.nodeData, text, 'text', true)
40
+ }
41
+ }
42
+
43
+
44
+ class JSXExpressionConverter extends BaseConverter {
45
+ convert() {
46
+ // This only treats expressions in innerText, not attribute values.
47
+ this.element = doc.createElement('text')
48
+ this.element.textContent = '.'
49
+ const code = this.readCode(this.astNode.expression)
50
+ addInlineWatches(this.nodeData, `{${code}}`, 'text', true)
51
+ }
52
+ }
53
+
54
+
55
+ class JSXElementConverter extends BaseConverter {
56
+ convert() {
57
+ const openingElement = this.astNode.openingElement
58
+ let tagName = this.getTagName()
59
+
60
+ // Can't put error against attribute. Maybe I need to visit?
61
+ this.nodeData.attributes = openingElement.attributes.map(attr => new AttributeInfo(this.path, attr))
62
+ this.nodeData.attributeNames = this.nodeData.attributes.map(attr => attr.name)
63
+
64
+ this.element = doc.createElement(tagName)
65
+
66
+ if (isCapitalized(tagName) || tagName.includes(".")) {
67
+ this.nodeData.nestedClass = tagName
68
+ }
69
+
70
+ this.nodeData.attributes.forEach(attr => this.processAttribute(attr))
71
+
72
+ // TODO: check siblings and children here or in directive (or NodeData.assertXYZ()..)
73
+ if (this.nodeData.nestedClass) {
74
+ if (this.nodeData.isRepeat) {
75
+ this.element = null
76
+ } else {
77
+ this.element = doc.createElement("br")
78
+ this.nodeData.replaceWith = tagName
79
+ }
80
+ }
81
+ }
82
+ getTagName() {
83
+ // could be 'JSXMemberExpression' or 'JSXIdentifier', so just read as string.
84
+ return this.readCode(this.astNode.openingElement.name)
85
+ }
86
+ processAttribute = (attr) => {
87
+ const {argType, name, value} = attr
88
+ const nameLowerCase = name.toLowerCase()
89
+ const directive = directives.hasOwnProperty(nameLowerCase) && directives[nameLowerCase]
90
+ if (directive && ! attr.escaped) {
91
+ this.processDirective(directive, nameLowerCase, attr)
92
+ } else {
93
+ switch (argType) {
94
+ case "expr":
95
+ addInlineWatches(this.nodeData, value, `@${attr.fullName}`, false)
96
+ break
97
+ case "null":
98
+ // TODO: this shows as <div disabled=""></div> which works in Chrome etc...
99
+ // but its annoying for tests and compactness. Seems to be a JSDOM issue?
100
+ this.element.setAttribute(attr.fullName, "")
101
+ // this.element.setAttribute(name, null)
102
+ // this.element[name] = true
103
+ break
104
+ case "str":
105
+ this.element.setAttribute(attr.fullName, value)
106
+ break
107
+ }
108
+ }
109
+ }
110
+ processDirective = (directive, directiveName, attr) => {
111
+ const allowedArgTypes = directive.allow.split("|")
112
+ if (!allowedArgTypes.includes(attr.argType)) {
113
+ throw this.path.buildCodeFrameError(
114
+ `Directive [${directiveName}] does not allow argument type: "${attr.argType}"`
115
+ )
116
+ }
117
+ if (directive.qualifier === "no" && attr.qualifier) {
118
+ throw this.path.buildCodeFrameError(
119
+ `Directive [${directiveName}] does not allow a qualifier.`
120
+ )
121
+ }
122
+ if (directive.qualifier === "yes" && !attr.qualifier) {
123
+ throw this.path.buildCodeFrameError(
124
+ `Directive [${directiveName}] requires a qualifier.`
125
+ )
126
+ }
127
+ // TODO: parse args better.
128
+ directive.handle(this.nodeData, attr)
129
+ }
130
+ }
131
+
132
+
133
+ /**
134
+ * An attribute could look like:
135
+ *
136
+ * <div name>
137
+ * <div name="value">
138
+ * <div name={expr}>
139
+ * <div namespace:name>
140
+ * <div _name>
141
+ *
142
+ */
143
+ class AttributeInfo {
144
+ constructor(path, attr) {
145
+ this.path = path
146
+ this.astAttribute = attr
147
+ this.name = undefined
148
+ this.nestedClass = undefined
149
+ this.qualifier = undefined
150
+ this.escaped = false
151
+ this.argType = undefined
152
+ this.value = undefined
153
+ this.arg = undefined
154
+ this.args = undefined
155
+ this.processName()
156
+ this.processValue()
157
+ }
158
+ processName() {
159
+ const name = this.astAttribute.name
160
+ if (name.type === "JSXIdentifier") {
161
+ this.name = name.name
162
+ } else if (name.type === "JSXNamespacedName") {
163
+ this.name = name.namespace.name
164
+ this.qualifier = name.name.name
165
+ } else {
166
+ throw this.path.buildCodeFrameError(`Unknown attribute name type: ${name.type}`)
167
+ }
168
+ if (this.name.startsWith("_")) {
169
+ this.name = this.name.slice(1)
170
+ this.escaped = true
171
+ }
172
+ this.fullName = this.name + (this.qualifier ? `:${this.qualifier}` : "")
173
+ }
174
+ processValue() {
175
+ const value = this.astAttribute.value
176
+ if (value === null) {
177
+ this.arg = null
178
+ this.argType = "null"
179
+ }
180
+ else if (value.type === "StringLiteral") {
181
+ this.value = value.value
182
+ this.argType = "str"
183
+ }
184
+ else if (value.type === "JSXExpressionContainer") {
185
+ this.argType = "expr"
186
+ // Detecting BinaryExpression through AST is a PITA. Strings are much simpler.
187
+ const code = readCode(this.path, value.expression).replaceAll("||", splitter)
188
+ this.args = code.split("|").map(x => x.replaceAll(splitter, "||").trim())
189
+ this.arg = this.args[0]
190
+ this.value = `{${readCode(this.path, value.expression)}}`
191
+ }
192
+ }
193
+ }
194
+
195
+
196
+ const converterClasses = {
197
+ JSXText: JSXTextConverter,
198
+ JSXExpressionContainer: JSXExpressionConverter,
199
+ JSXElement: JSXElementConverter
200
+ }
201
+
202
+
203
+ const extractNodeData = (componentName, path, astNode, parentNodeData, nodeTreeAddress) => {
204
+ const cls = converterClasses[astNode.type]
205
+ if (!cls) {
206
+ throw this.path.buildCodeFrameError(`Unexpected node type: ${astNode.type}`)
207
+ }
208
+ const converter = new cls(componentName, path, astNode, parentNodeData, nodeTreeAddress)
209
+ converter.convert()
210
+ return {element: converter.element, nodeData: converter.nodeData}
211
+ }
212
+
213
+
214
+ module.exports = {extractNodeData}
@@ -0,0 +1,98 @@
1
+ const { JSXText } = require('@babel/types')
2
+ const {readCode} = require('../utils/babel')
3
+ const {extractNodeData} = require('./extract')
4
+ const {ComponentDOM} = require('./component_dom')
5
+
6
+
7
+ /**
8
+ * This parses the JSX code to collect dynamicNodes and the html string.
9
+ */
10
+ class JSXParser {
11
+ constructor(path, componentName) {
12
+ this._path = path
13
+ this._componentName = componentName
14
+ this._dom = new ComponentDOM()
15
+ this._nodes = []
16
+ this.dynamicNodes = []
17
+ this.html = undefined
18
+ }
19
+ parse() {
20
+ this._walkJSXTree(this._path.node, undefined)
21
+ // Determine this at the end rather than as we go, as directives may turn parent nodes dynamic.
22
+ this.dynamicNodes = this._nodes.filter(node => node.isDynamic())
23
+ this.html = this._dom.getHtmlString()
24
+ }
25
+ _walkJSXTree(astNode, i, parentNodeData) {
26
+ this._dom.push(i)
27
+ const {element, nodeData} = extractNodeData(
28
+ this._componentName,
29
+ this._path,
30
+ astNode,
31
+ parentNodeData,
32
+ this._dom.getCurrentAddress()
33
+ )
34
+ if (element) {
35
+ this._dom.attach(element)
36
+ }
37
+ this._nodes.push(nodeData)
38
+ const childAstNodes = this._squashChildren(astNode)
39
+ childAstNodes.forEach((node, i) => this._walkJSXTree(node, i, nodeData))
40
+ this._dom.pop()
41
+ }
42
+ _squashChildren(astNode) {
43
+ if (astNode.type != 'JSXElement') {
44
+ return []
45
+ }
46
+ const children = []
47
+ const sequence = []
48
+
49
+ const flush = () => {
50
+ if (sequence.length === 1) {
51
+ const node = sequence[0]
52
+ if (node.type == 'JSXExpressionContainer') {
53
+ const text = readCode(this._path, node)
54
+ children.push(JSXText(text))
55
+ } else {
56
+ children.push(node)
57
+ }
58
+ } else {
59
+ children.push(...sequence)
60
+ }
61
+ sequence.length = 0
62
+ }
63
+
64
+ astNode.children.forEach(child => {
65
+ switch (child.type) {
66
+ case 'JSXText':
67
+ if (child.value.trim()?.length) {
68
+ sequence.push(child)
69
+ }
70
+ break
71
+ case 'JSXExpressionContainer':
72
+ sequence.push(child)
73
+ break
74
+ case 'JSXElement':
75
+ flush()
76
+ children.push(child)
77
+ break
78
+ default:
79
+ throw new Error('Unexpected node type: ' + child.type)
80
+ }
81
+ })
82
+ flush()
83
+ return children
84
+ }
85
+ }
86
+
87
+
88
+ const parseJSX = (path, componentName) => {
89
+ const parser = new JSXParser(path, componentName)
90
+ parser.parse()
91
+ return {
92
+ componentName: componentName,
93
+ html: parser.html,
94
+ dynamicNodes: parser.dynamicNodes
95
+ }
96
+ }
97
+
98
+ module.exports = {parseJSX}
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Feature temporarily disabled
3
+ *
4
+ * This relates to using HTML template files instead of HTML string.
5
+ */
6
+
7
+ const fs = require("fs")
8
+ const path = require('path')
9
+ const {parseHTML, stripHtml} = require('../utils/dom')
10
+
11
+ /**
12
+ * A pool of component templates by path. These are files, typically called components.html
13
+ * which contain the HTML for components.
14
+ */
15
+ class ComponentTemplatePool {
16
+ constructor() {
17
+ this._filePools = {}
18
+ }
19
+ getHtml(filepath, className) {
20
+ const dir = path.dirname(filepath)
21
+ const templateFile = path.join(dir, 'components.html')
22
+ if (fs.existsSync(templateFile)) {
23
+ let pooldComponents = this._filePools[templateFile]
24
+ if (pooldComponents === undefined) {
25
+ pooldComponents = this.addToPool(templateFile)
26
+ }
27
+ return pooldComponents[className]
28
+ }
29
+ }
30
+ addToPool(templateFile) {
31
+ const contents = fs.readFileSync(templateFile, {encoding:'utf8', flag:'r'})
32
+ const dom = parseHTML(stripHtml(contents)) // Must strip!
33
+ const filePool = {}
34
+ const childNodes = Array.from(dom.childNodes)
35
+ childNodes.forEach(n => {
36
+ filePool[n.tagName] = n.childNodes[0].outerHTML
37
+ })
38
+ this._filePools[templateFile] = filePool
39
+ return filePool
40
+ }
41
+ }
42
+
43
+ const componentTemplates = new ComponentTemplatePool()
44
+ module.exports = {componentTemplates}