Skip to content

Commit d27f465

Browse files
committed
Add support for non-literal template variables
1 parent 04cba18 commit d27f465

File tree

2 files changed

+121
-76
lines changed

2 files changed

+121
-76
lines changed

packages/babel-plugin-htm/index.mjs

Lines changed: 78 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ import htm from 'htm';
66
* @param {string} [options.pragma=h] JSX/hyperscript pragma.
77
* @param {string} [options.tag=html] The tagged template "tag" function name to process.
88
* @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.
910
*/
1011
export default function htmBabelPlugin({ types: t }, options = {}) {
1112
const pragma = options.pragma===false ? false : dottedIdentifier(options.pragma || 'h');
12-
13+
const useBuiltIns = options.useBuiltIns;
1314
const inlineVNodes = options.monomorphic || pragma===false;
1415

1516
function dottedIdentifier(keypath) {
@@ -59,91 +60,91 @@ export default function htmBabelPlugin({ types: t }, options = {}) {
5960

6061
return t.callExpression(pragma, [tag, props, children]);
6162
}
62-
63-
let isVNode = t.isCallExpression;
64-
if (inlineVNodes) {
65-
isVNode = node => {
66-
if (!t.isObjectExpression(node)) return false;
67-
return node.properties[0].value.value!==3;
68-
};
69-
}
70-
71-
function childMapper(child, index, children) {
72-
// JSX-style whitespace: (@TODO: remove? doesn't match the browser version)
73-
if (typeof child==='string' && child.trim().length===0 || child==null) {
74-
if (index===0 || index===children.length-1) return null;
75-
}
76-
if (typeof child==='string' && isVNode(children[index-1]) && isVNode(children[index+1])) {
77-
child = child.trim();
78-
}
79-
if (typeof child==='string') {
80-
return stringValue(child);
63+
64+
function findNextNode(args, start) {
65+
for (let i = start; i < args.length; i++) {
66+
if (t.isNode(args[i])) {
67+
return i;
68+
}
8169
}
82-
return child;
70+
return args.length;
8371
}
84-
85-
function h(tag, props) {
86-
if (typeof tag==='string') {
87-
tag = t.stringLiteral(tag);
72+
73+
function flattenSpread(args) {
74+
const flattened = [];
75+
for (let i = 0; i < args.length; i++) {
76+
if (t.isNode(args[i])) {
77+
flattened.push(args[i]);
78+
}
79+
else {
80+
const start = i;
81+
const end = findNextNode(args, start + 1);
82+
flattened.push(Object.assign(...args.slice(start, end)));
83+
i = end - 1;
84+
}
8885
}
86+
return flattened;
87+
}
88+
89+
function spreadNode(args, state) {
90+
args = flattenSpread(args);
8991

90-
let propsNode;
91-
92-
if (t.isObjectExpression(props)) {
93-
propsNode = props;
94-
for (let i in props) {
95-
if (props.hasOwnProperty(i) && props[i] && props[i].type) {
96-
for (let j=0; j<props.properties.length; j++) {
97-
if (props.properties[j].start > props[i].start) {
98-
props.properties.splice(j, 0, t.objectProperty(propertyName(i), props[i]));
99-
break;
100-
}
101-
}
102-
delete props[i];
103-
}
104-
}
92+
// Case 'Object.assign(x)' can be collapsed to 'x'.
93+
if (args.length === 1) {
94+
return propsNode(args[0]);
10595
}
106-
else {
107-
propsNode = t.objectExpression(
108-
Object.keys(props).map(key => {
109-
let value = props[key];
110-
if (typeof value==='string') {
111-
value = t.stringLiteral(value);
112-
}
113-
else if (typeof value==='boolean') {
114-
value = t.booleanLiteral(value);
115-
}
116-
else if (typeof value==='number') {
117-
value = t.stringLiteral(value + '');
118-
}
119-
return t.objectProperty(propertyName(key), value);
120-
})
121-
);
96+
// Case 'Object.assign({}, x)', can be collapsed to 'x'.
97+
if (args.length === 2 && !t.isNode(args[0]) && Object.keys(args[0]).length === 0) {
98+
return propsNode(args[1]);
12299
}
123-
124-
// recursive iteration of possibly nested arrays of children.
125-
let children = [];
126-
if (arguments.length>2) {
127-
const stack = [];
128-
// eslint-disable-next-line prefer-rest-params
129-
for (let i=arguments.length; i-->2; ) stack.push(arguments[i]);
130-
while (stack.length) {
131-
const child = stack.pop();
132-
if (Array.isArray(child)) {
133-
for (let i=child.length; i--; ) stack.push(child[i]);
100+
const helper = useBuiltIns ? dottedIdentifier('Object.assign') : state.addHelper('extends');
101+
return t.callExpression(helper, args.map(propsNode));
102+
}
103+
104+
function propsNode(props) {
105+
return t.isNode(props) ? props : t.objectExpression(
106+
Object.keys(props).map(key => {
107+
let value = props[key];
108+
if (typeof value==='string') {
109+
value = t.stringLiteral(value);
134110
}
135-
else if (child!=null) {
136-
children.push(child);
111+
else if (typeof value==='boolean') {
112+
value = t.booleanLiteral(value);
137113
}
114+
return t.objectProperty(propertyName(key), value);
115+
})
116+
);
117+
}
118+
119+
function transform({ tag, props, children }, state) {
120+
function childMapper(child) {
121+
if (typeof child==='string') {
122+
return stringValue(child);
138123
}
139-
children = children.map(childMapper).filter(Boolean);
124+
return t.isNode(child) ? child : transform(child, state);
140125
}
141-
children = t.arrayExpression(children);
142-
143-
return createVNode(tag, propsNode, children);
126+
const newTag = typeof tag === 'string' ? t.stringLiteral(tag) : tag;
127+
const newProps = !Array.isArray(props) ? propsNode(props) : spreadNode(props, state);
128+
const newChildren = t.arrayExpression(children.map(childMapper));
129+
return createVNode(newTag, newProps, newChildren);
144130
}
145131

132+
function h(tag, props, ...children) {
133+
return { tag, props, children };
134+
}
135+
146136
const html = htm.bind(h);
137+
138+
function treeify(statics, expr) {
139+
const assign = Object.assign;
140+
try {
141+
Object.assign = function(...objs) { return objs; };
142+
return html(statics, ...expr);
143+
}
144+
finally {
145+
Object.assign = assign;
146+
}
147+
}
147148

148149
// The tagged template tag function name we're looking for.
149150
// This is static because it's generally assigned via htm.bind(h),
@@ -152,12 +153,14 @@ export default function htmBabelPlugin({ types: t }, options = {}) {
152153
return {
153154
name: 'htm',
154155
visitor: {
155-
TaggedTemplateExpression(path) {
156+
TaggedTemplateExpression(path, state) {
156157
const tag = path.node.tag.name;
157158
if (htmlName[0]==='/' ? patternStringToRegExp(htmlName).test(tag) : tag === htmlName) {
158159
const statics = path.node.quasi.quasis.map(e => e.value.raw);
159160
const expr = path.node.quasi.expressions;
160-
path.replaceWith(html(statics, ...expr));
161+
162+
const tree = treeify(statics, expr);
163+
path.replaceWith(transform(tree, state));
161164
}
162165
}
163166
}

test/babel.test.mjs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,55 @@ describe('htm/babel', () => {
3333
test('basic nested transformation', () => {
3434
expect(
3535
transform('html`<a b=${2} ...${{ c: 3 }}>d: ${4}</a>`;', {
36+
babelrc: false,
37+
compact: true,
38+
plugins: [
39+
[htmBabelPlugin, {
40+
useBuiltIns: true
41+
}]
42+
]
43+
}).code
44+
).toBe(`h("a",Object.assign({b:2},{c:3}),["d: ",4]);`);
45+
});
46+
47+
test('spread a single variable', () => {
48+
expect(
49+
transform('html`<a ...${foo}></a>`;', {
3650
babelrc: false,
3751
compact: true,
3852
plugins: [
3953
htmBabelPlugin
4054
]
4155
}).code
42-
).toBe(`h("a",{b:2,c:3},["d: ",4]);`);
56+
).toBe(`h("a",foo,[]);`);
57+
});
58+
59+
test('spread a 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('mix-and-match spreads', () => {
74+
expect(
75+
transform('html`<a b="1" ...${foo} c=${2} ...${{d:3}}></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,{c:2},{d:3}),[]);`);
4385
});
4486

4587
test('inline vnode transformation: (pragma:false)', () => {

0 commit comments

Comments
 (0)