Skip to content

Commit 15eff25

Browse files
authored
Merge pull request #43 from developit/babel-plugin-fix-redux
Attempt to fix babel plugin
2 parents 9a8c8f0 + 755ec29 commit 15eff25

File tree

4 files changed

+205
-90
lines changed

4 files changed

+205
-90
lines changed

packages/babel-plugin-htm/index.mjs

Lines changed: 60 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,17 @@
11
import htm from 'htm';
22

3-
// htm() uses the HTML parser, which serializes attribute values.
4-
// this is a problem, because composite values here can be made up
5-
// of strings and AST nodes, which serialize to [object Object].
6-
// Since the handoff from AST node handling to htm() is synchronous,
7-
// this global lookup will always reflect the corresponding
8-
// AST-derived values for the current htm() invocation.
9-
let currentExpressions;
10-
113
/**
124
* @param {Babel} babel
135
* @param {object} options
146
* @param {string} [options.pragma=h] JSX/hyperscript pragma.
157
* @param {string} [options.tag=html] The tagged template "tag" function name to process.
168
* @param {boolean} [options.monomorphic=false] Output monomorphic inline objects instead of using String literals.
9+
* @param {boolean} [options.useBuiltIns=false] Use the native Object.assign instead of trying to polyfill it.
10+
* @param {boolean} [options.variableArity=true] If `false`, always passes exactly 3 arguments to the pragma function.
1711
*/
1812
export default function htmBabelPlugin({ types: t }, options = {}) {
1913
const pragma = options.pragma===false ? false : dottedIdentifier(options.pragma || 'h');
20-
14+
const useBuiltIns = options.useBuiltIns;
2115
const inlineVNodes = options.monomorphic || pragma===false;
2216

2317
function dottedIdentifier(keypath) {
@@ -55,6 +49,11 @@ export default function htmBabelPlugin({ types: t }, options = {}) {
5549
}
5650

5751
function createVNode(tag, props, children) {
52+
// Never pass children=[[]].
53+
if (children.elements.length === 1 && t.isArrayExpression(children.elements[0]) && children.elements[0].elements.length === 0) {
54+
children = children.elements[0];
55+
}
56+
5857
if (inlineVNodes) {
5958
return t.objectExpression([
6059
options.monomorphic && t.objectProperty(propertyName('type'), t.numericLiteral(1)),
@@ -64,93 +63,75 @@ export default function htmBabelPlugin({ types: t }, options = {}) {
6463
options.monomorphic && t.objectProperty(propertyName('text'), t.nullLiteral())
6564
].filter(Boolean));
6665
}
67-
68-
return t.callExpression(pragma, [tag, props, children]);
69-
}
7066

71-
let isVNode = t.isCallExpression;
72-
if (inlineVNodes) {
73-
isVNode = node => {
74-
if (!t.isObjectExpression(node)) return false;
75-
return node.properties[0].value.value!==3;
76-
};
77-
}
78-
79-
function childMapper(child, index, children) {
80-
// JSX-style whitespace: (@TODO: remove? doesn't match the browser version)
81-
if (typeof child==='string' && child.trim().length===0 || child==null) {
82-
if (index===0 || index===children.length-1) return null;
83-
}
84-
if (typeof child==='string' && isVNode(children[index-1]) && isVNode(children[index+1])) {
85-
child = child.trim();
86-
}
87-
if (typeof child==='string') {
88-
const matches = child.match(/\$\$\$_h_\[(\d+)\]/);
89-
if (matches) return currentExpressions[matches[1]];
90-
return stringValue(child);
67+
// Passing `{variableArity:false}` always produces `h(tag, props, children)` - where `children` is always an Array.
68+
// Otherwise, the default is `h(tag, props, ...children)`.
69+
if (options.variableArity !== false) {
70+
children = children.elements;
9171
}
92-
return child;
93-
}
9472

95-
function h(tag, props) {
96-
if (typeof tag==='string') {
97-
const matches = tag.match(/\$\$\$_h_\[(\d+)\]/);
98-
if (matches) tag = currentExpressions[matches[1]];
99-
else tag = t.stringLiteral(tag);
73+
return t.callExpression(pragma, [tag, props].concat(children));
74+
}
75+
76+
function spreadNode(args, state) {
77+
// 'Object.assign({}, x)', can be collapsed to 'x'.
78+
if (args.length === 2 && !t.isNode(args[0]) && Object.keys(args[0]).length === 0) {
79+
return propsNode(args[1]);
10080
}
81+
const helper = useBuiltIns ? dottedIdentifier('Object.assign') : state.addHelper('extends');
82+
return t.callExpression(helper, args.map(propsNode));
83+
}
84+
85+
function propsNode(props) {
86+
if (props == null) return t.nullLiteral();
10187

102-
//const propsNode = props==null || Object.keys(props).length===0 ? t.nullLiteral() : t.objectExpression(
103-
const propsNode = t.objectExpression(
88+
return t.isNode(props) ? props : t.objectExpression(
10489
Object.keys(props).map(key => {
10590
let value = props[key];
10691
if (typeof value==='string') {
107-
const tokenizer = /\$\$\$_h_\[(\d+)\]/g;
108-
let token, lhs, root, index=0, lastIndex=0;
109-
const append = expr => {
110-
if (lhs) expr = t.binaryExpression('+', lhs, expr);
111-
root = lhs = expr;
112-
};
113-
while ((token = tokenizer.exec(value))) {
114-
append(t.stringLiteral(value.substring(index, token.index)));
115-
append(currentExpressions[token[1]]);
116-
index = token.index;
117-
lastIndex = tokenizer.lastIndex;
118-
}
119-
if (lastIndex < value.length) {
120-
append(t.stringLiteral(value.substring(lastIndex)));
121-
}
122-
value = root;
92+
value = t.stringLiteral(value);
12393
}
12494
else if (typeof value==='boolean') {
12595
value = t.booleanLiteral(value);
12696
}
12797
return t.objectProperty(propertyName(key), value);
12898
})
12999
);
100+
}
130101

131-
// recursive iteration of possibly nested arrays of children.
132-
let children = [];
133-
if (arguments.length>2) {
134-
const stack = [];
135-
// eslint-disable-next-line prefer-rest-params
136-
for (let i=arguments.length; i-->2; ) stack.push(arguments[i]);
137-
while (stack.length) {
138-
const child = stack.pop();
139-
if (Array.isArray(child)) {
140-
for (let i=child.length; i--; ) stack.push(child[i]);
141-
}
142-
else if (child!=null) {
143-
children.push(child);
144-
}
102+
function transform(node, state) {
103+
if (node === undefined) return t.identifier('undefined');
104+
if (node == null) return t.nullLiteral();
105+
106+
const { tag, props, children } = node;
107+
function childMapper(child) {
108+
if (typeof child==='string') {
109+
return stringValue(child);
145110
}
146-
children = children.map(childMapper).filter(Boolean);
111+
return t.isNode(child) ? child : transform(child, state);
147112
}
148-
children = t.arrayExpression(children);
149-
150-
return createVNode(tag, propsNode, children);
113+
const newTag = typeof tag === 'string' ? t.stringLiteral(tag) : tag;
114+
const newProps = !Array.isArray(props) ? propsNode(props) : spreadNode(props, state);
115+
const newChildren = t.arrayExpression(children.map(childMapper));
116+
return createVNode(newTag, newProps, newChildren);
151117
}
152118

119+
function h(tag, props, ...children) {
120+
return { tag, props, children };
121+
}
122+
153123
const html = htm.bind(h);
124+
125+
function treeify(statics, expr) {
126+
const assign = Object.assign;
127+
try {
128+
Object.assign = function(...objs) { return objs; };
129+
return html(statics, ...expr);
130+
}
131+
finally {
132+
Object.assign = assign;
133+
}
134+
}
154135

155136
// The tagged template tag function name we're looking for.
156137
// This is static because it's generally assigned via htm.bind(h),
@@ -159,13 +140,14 @@ export default function htmBabelPlugin({ types: t }, options = {}) {
159140
return {
160141
name: 'htm',
161142
visitor: {
162-
TaggedTemplateExpression(path) {
143+
TaggedTemplateExpression(path, state) {
163144
const tag = path.node.tag.name;
164145
if (htmlName[0]==='/' ? patternStringToRegExp(htmlName).test(tag) : tag === htmlName) {
165146
const statics = path.node.quasi.quasis.map(e => e.value.raw);
166147
const expr = path.node.quasi.expressions;
167-
currentExpressions = expr;
168-
path.replaceWith(html(statics, ...expr.map((p, i) => `$$$_h_[${i}]`)));
148+
149+
const tree = treeify(statics, expr);
150+
path.replaceWith(transform(tree, state));
169151
}
170152
}
171153
}

src/index.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ const build = (statics) => {
7171
if (!spreadClose) {
7272
spreadClose = ')';
7373
if (!props) props = 'Object.assign({}';
74-
else props = 'Object.assign({},' + props;
74+
else props = 'Object.assign(' + props;
7575
}
7676
props += propsClose + ',' + field;
7777
propsClose = '';

test/babel.test.mjs

Lines changed: 137 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ describe('htm/babel', () => {
1515
htmBabelPlugin
1616
]
1717
}).code
18-
).toBe(`h("div",{id:"hello"},["hello"]);`);
18+
).toBe(`h("div",{id:"hello"},"hello");`);
1919
});
2020

2121
test('basic transformation with variable', () => {
@@ -27,34 +27,160 @@ describe('htm/babel', () => {
2727
htmBabelPlugin
2828
]
2929
}).code
30-
).toBe(`var name="world";h("div",{id:"hello"},["hello, ",name]);`);
30+
).toBe(`var name="world";h("div",{id:"hello"},"hello, ",name);`);
3131
});
3232

33-
test('inline vnode transformation: (pragma:false)', () => {
33+
test('basic nested transformation', () => {
3434
expect(
35-
transform('var name="world",vnode=html`<div id=hello>hello, ${name}</div>`;', {
35+
transform('html`<a b=${2} ...${{ c: 3 }}>d: ${4}</a>`;', {
3636
babelrc: false,
3737
compact: true,
3838
plugins: [
3939
[htmBabelPlugin, {
40-
pragma: false
40+
useBuiltIns: true
4141
}]
4242
]
4343
}).code
44-
).toBe(`var name="world",vnode={tag:"div",props:{id:"hello"},children:["hello, ",name]};`);
44+
).toBe(`h("a",Object.assign({b:2},{c:3}),"d: ",4);`);
4545
});
46-
47-
test('monomorphic transformation', () => {
46+
47+
test('spread a single variable', () => {
48+
expect(
49+
transform('html`<a ...${foo}></a>`;', {
50+
babelrc: false,
51+
compact: true,
52+
plugins: [
53+
htmBabelPlugin
54+
]
55+
}).code
56+
).toBe(`h("a",foo);`);
57+
});
58+
59+
test('spread two variables', () => {
60+
expect(
61+
transform('html`<a ...${foo} ...${bar}></a>`;', {
62+
babelrc: false,
63+
compact: true,
64+
plugins: [
65+
[htmBabelPlugin, {
66+
useBuiltIns: true
67+
}]
68+
]
69+
}).code
70+
).toBe(`h("a",Object.assign({},foo,bar));`);
71+
});
72+
73+
test('property followed by a spread', () => {
74+
expect(
75+
transform('html`<a b="1" ...${foo}></a>`;', {
76+
babelrc: false,
77+
compact: true,
78+
plugins: [
79+
[htmBabelPlugin, {
80+
useBuiltIns: true
81+
}]
82+
]
83+
}).code
84+
).toBe(`h("a",Object.assign({b:"1"},foo));`);
85+
});
86+
87+
test('spread followed by a property', () => {
88+
expect(
89+
transform('html`<a ...${foo} b="1"></a>`;', {
90+
babelrc: false,
91+
compact: true,
92+
plugins: [
93+
[htmBabelPlugin, {
94+
useBuiltIns: true
95+
}]
96+
]
97+
}).code
98+
).toBe(`h("a",Object.assign({},foo,{b:"1"}));`);
99+
});
100+
101+
test('mix-and-match spreads', () => {
48102
expect(
49-
transform('var name="world",vnode=html`<div id=hello>hello, ${name}</div>`;', {
103+
transform('html`<a b="1" ...${foo} c=${2} ...${{d:3}}></a>`;', {
50104
babelrc: false,
51105
compact: true,
52106
plugins: [
53107
[htmBabelPlugin, {
54-
monomorphic: true
108+
useBuiltIns: true
55109
}]
56110
]
57111
}).code
58-
).toBe(`var name="world",vnode={type:1,tag:"div",props:{id:"hello"},children:[{type:3,tag:null,props:null,children:null,text:"hello, "},name],text:null};`);
112+
).toBe(`h("a",Object.assign({b:"1"},foo,{c:2},{d:3}));`);
113+
});
114+
115+
describe('{variableArity:false}', () => {
116+
test('should pass no children as an empty Array', () => {
117+
expect(
118+
transform('html`<div />`;', {
119+
babelrc: false,
120+
compact: true,
121+
plugins: [
122+
[htmBabelPlugin, {
123+
variableArity: false
124+
}]
125+
]
126+
}).code
127+
).toBe(`h("div",null,[]);`);
128+
});
129+
130+
test('should pass children as an Array', () => {
131+
expect(
132+
transform('html`<div id=hello>hello</div>`;', {
133+
babelrc: false,
134+
compact: true,
135+
plugins: [
136+
[htmBabelPlugin, {
137+
variableArity: false
138+
}]
139+
]
140+
}).code
141+
).toBe(`h("div",{id:"hello"},["hello"]);`);
142+
});
143+
});
144+
145+
describe('{pragma:false}', () => {
146+
test('should transform to inline vnodes', () => {
147+
expect(
148+
transform('var name="world",vnode=html`<div id=hello>hello, ${name}</div>`;', {
149+
babelrc: false,
150+
compact: true,
151+
plugins: [
152+
[htmBabelPlugin, {
153+
pragma: false
154+
}]
155+
]
156+
}).code
157+
).toBe(`var name="world",vnode={tag:"div",props:{id:"hello"},children:["hello, ",name]};`);
158+
});
159+
});
160+
161+
describe('{monomorphic:true}', () => {
162+
test('should transform to monomorphic inline vnodes', () => {
163+
expect(
164+
transform('var name="world",vnode=html`<div id=hello>hello, ${name}</div>`;', {
165+
babelrc: false,
166+
compact: true,
167+
plugins: [
168+
[htmBabelPlugin, {
169+
monomorphic: true
170+
}]
171+
]
172+
}).code
173+
).toBe(`var name="world",vnode={type:1,tag:"div",props:{id:"hello"},children:[{type:3,tag:null,props:null,children:null,text:"hello, "},name],text:null};`);
174+
});
175+
});
176+
177+
describe('main test suite', () => {
178+
// Run all of the main tests against the Babel plugin:
179+
const mod = require('fs').readFileSync(require('path').resolve(__dirname, 'index.test.mjs'), 'utf8').replace(/\\0/g, '\0');
180+
const { code } = transform(mod.replace(/^\s*import\s*.+?\s*from\s+(['"]).*?\1[\s;]*$/im, 'const htm = function(){};'), {
181+
babelrc: false,
182+
plugins: [htmBabelPlugin]
183+
});
184+
eval(code);
59185
});
60186
});

0 commit comments

Comments
 (0)