diff --git a/lib/babel.js b/lib/babel.js index 99af272..d34d791 100644 --- a/lib/babel.js +++ b/lib/babel.js @@ -1,291 +1,11 @@ 'use strict' -const camelCase = require('camel-case') -const hyperx = require('hyperx') -const SVG_TAGS = require('./svg-tags') -const BOOL_PROPS = require('./bool-props') - -const SVGNS = 'http://www.w3.org/2000/svg' -const XLINKNS = 'http://www.w3.org/1999/xlink' - -/** - * Try to return a nice variable name for an element based on its HTML id, - * classname, or tagname. - */ -function getElementName (props, tag) { - if (typeof props.id === 'string' && !placeholderRe.test(props.id)) { - return camelCase(props.id) - } - if (typeof props.className === 'string' && !placeholderRe.test(props.className)) { - return camelCase(props.className.split(' ')[0]) - } - return tag || 'nanohtml' -} - -/** - * Regex for detecting placeholders. - */ -const placeholderRe = /\0(\d+)\0/g - -/** - * Get a placeholder string for a numeric ID. - */ -const getPlaceholder = (i) => `\0${i}\0` - -/** - * Remove a binding and its import or require() call from the file. - */ -function removeBindingImport (binding) { - const path = binding.path - if (path.parentPath.isImportDeclaration() && - // Remove the entire Import if this is the only imported binding. - path.parentPath.node.specifiers.length === 1) { - path.parentPath.remove() - } else { - path.remove() - } -} +const transform = require('./transform') module.exports = (babel) => { const t = babel.types const nanohtmlModuleNames = ['nanohtml', 'bel', 'yo-yo', 'choo/html'] - /** - * Returns a node that creates a namespaced HTML element. - */ - const createNsElement = (ns, tag) => - t.callExpression( - t.memberExpression(t.identifier('document'), t.identifier('createElementNS')), - [ns, t.stringLiteral(tag)] - ) - - /** - * Returns a node that creates an element. - */ - const createElement = (tag) => - t.callExpression( - t.memberExpression(t.identifier('document'), t.identifier('createElement')), - [t.stringLiteral(tag)] - ) - - /** - * Returns a node that creates a comment. - */ - const createComment = (text) => - t.callExpression( - t.memberExpression(t.identifier('document'), t.identifier('createComment')), - [t.stringLiteral(text)] - ) - - /** - * Returns a node that sets a DOM property. - */ - const setDomProperty = (id, prop, value) => - t.assignmentExpression('=', - t.memberExpression(id, t.identifier(prop)), - value) - - /** - * Returns a node that sets a DOM attribute. - */ - const setDomAttribute = (id, attr, value) => - t.callExpression( - t.memberExpression(id, t.identifier('setAttribute')), - [t.stringLiteral(attr), value]) - - const setDomAttributeNS = (id, attr, value, ns = t.nullLiteral()) => - t.callExpression( - t.memberExpression(id, t.identifier('setAttributeNS')), - [ns, t.stringLiteral(attr), value]) - - /** - * Returns a node that sets a boolean DOM attribute. - */ - const setBooleanAttribute = (id, attr, value) => - t.logicalExpression('&&', value, - setDomAttribute(id, attr, t.stringLiteral(attr))) - - /** - * Returns a node that appends children to an element. - */ - const appendChild = (appendChildId, id, children) => - t.callExpression( - appendChildId, - [id, t.arrayExpression(children)] - ) - - const addDynamicAttribute = (helperId, id, attr, value) => - t.callExpression(helperId, [id, attr, value]) - - /** - * Wrap a node in a String() call if it may not be a string. - */ - const ensureString = (node) => { - if (t.isStringLiteral(node)) { - return node - } - return t.callExpression(t.identifier('String'), [node]) - } - - /** - * Concatenate multiple parts of an HTML attribute. - */ - const concatAttribute = (left, right) => - t.binaryExpression('+', left, right) - - /** - * Check if a node is *not* the empty string. - * (Inverted so it can be used with `[].map` easily) - */ - const isNotEmptyString = (node) => - !t.isStringLiteral(node, { value: '' }) - - const isEmptyTemplateLiteral = (node) => { - return t.isTemplateLiteral(node) && - node.expressions.length === 0 && - node.quasis.length === 1 && - t.isTemplateElement(node.quasis[0]) && - node.quasis[0].value.raw === '' - } - - /** - * Transform a template literal into raw DOM calls. - */ - const nanohtmlify = (path, state) => { - if (isEmptyTemplateLiteral(path.node)) { - return t.unaryExpression('void', t.numericLiteral(0)) - } - - const quasis = path.node.quasis.map((quasi) => quasi.value.cooked) - const expressions = path.node.expressions - const expressionPlaceholders = expressions.map((expr, i) => getPlaceholder(i)) - - const root = hyperx(transform, { comments: true }).apply(null, - [quasis].concat(expressionPlaceholders)) - - /** - * Convert placeholders used in the template string back to the AST nodes - * they reference. - */ - function convertPlaceholders (value) { - // Probably AST nodes. - if (typeof value !== 'string') { - return [value] - } - - const items = value.split(placeholderRe) - let placeholder = true - return items.map((item) => { - placeholder = !placeholder - return placeholder ? expressions[item] : t.stringLiteral(item) - }) - } - - /** - * Transform a hyperx vdom element to an AST node that creates the element. - */ - function transform (tag, props, children) { - if (tag === '!--') { - return createComment(props.comment) - } - - const id = path.scope.generateUidIdentifier(getElementName(props, tag)) - path.scope.push({ id }) - - const result = [] - - // Use the SVG namespace for svg elements. - if (SVG_TAGS.includes(tag)) { - state.svgNamespaceId.used = true - result.push(t.assignmentExpression('=', id, createNsElement(state.svgNamespaceId, tag))) - } else { - result.push(t.assignmentExpression('=', id, createElement(tag))) - } - - Object.keys(props).forEach((propName) => { - const dynamicPropName = convertPlaceholders(propName).filter(isNotEmptyString) - // Just use the normal propName if there are no placeholders - if (dynamicPropName.length === 1 && t.isStringLiteral(dynamicPropName[0])) { - propName = dynamicPropName[0].value - } else { - state.setAttributeId.used = true - result.push(addDynamicAttribute(state.setAttributeId, id, dynamicPropName.reduce(concatAttribute), - convertPlaceholders(props[propName]).filter(isNotEmptyString).reduce(concatAttribute))) - return - } - - // don’t convert to lowercase, since some attributes are case-sensetive - let attrName = propName - - if (attrName === 'className') { - attrName = 'class' - } - - if (attrName === 'htmlFor') { - attrName = 'for' - } - - // abc.onclick = xyz - if (attrName.slice(0, 2) === 'on') { - const value = convertPlaceholders(props[propName]).filter(isNotEmptyString) - result.push(setDomProperty(id, attrName, - value.length === 1 - ? value[0] - : value.map(ensureString).reduce(concatAttribute) - )) - - return - } - - // Dynamic boolean attributes - if (BOOL_PROPS.indexOf(attrName) !== -1 && props[propName] !== attrName) { - // if (xyz) abc.setAttribute('disabled', 'disabled') - result.push(setBooleanAttribute(id, attrName, - convertPlaceholders(props[propName]) - .filter(isNotEmptyString)[0])) - return - } - - // use proper xml namespace for svg use links - if (attrName === 'xlink:href') { - const value = convertPlaceholders(props[propName]) - .map(ensureString) - .reduce(concatAttribute) - - state.xlinkNamespaceId.used = true - result.push(setDomAttributeNS(id, attrName, value, state.xlinkNamespaceId)) - - return - } - - // abc.setAttribute('class', xyz) - result.push(setDomAttribute(id, attrName, - convertPlaceholders(props[propName]) - .map(ensureString) - .reduce(concatAttribute) - )) - }) - - if (Array.isArray(children)) { - const realChildren = children.map(convertPlaceholders) - // Flatten - .reduce((flat, arr) => flat.concat(arr), []) - // Remove empty strings since they don't affect output - .filter(isNotEmptyString) - - if (realChildren.length > 0) { - state.appendChildId.used = true - result.push(appendChild(state.appendChildId, id, realChildren)) - } - } - - result.push(id) - return t.sequenceExpression(result) - } - - return root - } - function isNanohtmlRequireCall (node) { if (!t.isIdentifier(node.callee, { name: 'require' })) { return false @@ -300,6 +20,28 @@ module.exports = (babel) => { return nanohtmlModuleNames.indexOf(importFrom) !== -1 } + var apply = transform.factory({ + arrayExpression: t.arrayExpression, + objectExpression: t.objectExpression, + objectProperty: t.objectProperty, + stringLiteral: t.stringLiteral, + callCreateElement (html, tag, props, children) { + return t.callExpression( + t.memberExpression(html, t.identifier('createElement')), + [tag, props, children] + ) + }, + callObjectAssign (objects) { + return t.callExpression( + t.memberExpression(t.identifier('Object'), t.identifier('assign')), + objects + ) + }, + stringConcat: function (a, b) { + return t.binaryExpression('+', a, b) + } + }) + return { pre () { this.nanohtmlBindings = new Set() @@ -307,49 +49,54 @@ module.exports = (babel) => { post () { this.nanohtmlBindings.clear() }, - visitor: { Program: { - enter (path) { - this.appendChildId = path.scope.generateUidIdentifier('appendChild') - this.setAttributeId = path.scope.generateUidIdentifier('setAttribute') - this.svgNamespaceId = path.scope.generateUidIdentifier('svgNamespace') - this.xlinkNamespaceId = path.scope.generateUidIdentifier('xlinkNamespace') - }, exit (path, state) { - const appendChildModule = this.opts.appendChildModule || 'nanohtml/lib/append-child' - const setAttributeModule = this.opts.setAttributeModule || 'nanohtml/lib/set-attribute' - const useImport = this.opts.useImport - - if (this.appendChildId.used) { - addImport(this.appendChildId, appendChildModule) - } - if (this.setAttributeId.used) { - addImport(this.setAttributeId, setAttributeModule) - } - if (this.svgNamespaceId.used) { - path.scope.push({ - id: this.svgNamespaceId, - init: t.stringLiteral(SVGNS) - }) - } - if (this.xlinkNamespaceId.used) { - path.scope.push({ - id: this.xlinkNamespaceId, - init: t.stringLiteral(XLINKNS) + // Check every binding if it can be replaced with `createElement` implementation only + for (const binding of this.nanohtmlBindings) { + const replace = binding.referencePaths.every(function (reference) { + const node = reference.parentPath.node + if (!node) return true // node got removed + return t.isCallExpression(node) && node.callee.property.name === 'createElement' }) - } - function addImport (id, source) { - if (useImport) { - path.unshiftContainer('body', t.importDeclaration([ - t.importDefaultSpecifier(id) - ], t.stringLiteral(source))) - } else { - path.scope.push({ - id: id, - init: t.callExpression(t.identifier('require'), [t.stringLiteral(source)]) - }) + if (replace) { + var library + var identifier = binding.identifier + + if (binding.path.parentPath.isImportDeclaration()) { + library = binding.path.parentPath.node.source.value + binding.path.parentPath.remove() + } else { + library = binding.path.node.init.arguments[0].value + binding.path.remove() + } + + if (this.opts.useImport) { + var createElement = path.scope.generateUidIdentifier('createElement') + path.scope.push({ + id: identifier, + init: t.objectExpression([ + t.objectProperty( + t.stringLiteral('createElement'), + createElement + ) + ]) + }) + path.unshiftContainer('body', t.importDeclaration([ + t.importDefaultSpecifier(createElement) + ], t.stringLiteral(library + '/lib/createElement'))) + } else { + path.scope.push({ + id: identifier, + init: t.objectExpression([ + t.objectProperty( + t.stringLiteral('createElement'), + t.callExpression(t.identifier('require'), [t.stringLiteral(library + '/lib/createElement')]) + ) + ]) + }) + } } } } @@ -372,44 +119,32 @@ module.exports = (babel) => { if (isNanohtmlRequireCall(path.node)) { // Not a `thing = require(...)` declaration if (!path.parentPath.isVariableDeclarator()) return - this.nanohtmlBindings.add(path.parentPath.scope.getBinding(path.parentPath.node.id.name)) } }, TaggedTemplateExpression (path, state) { const tag = path.get('tag') + const quasi = path.get('quasi') const binding = tag.isIdentifier() ? path.scope.getBinding(tag.node.name) : null const isNanohtmlBinding = binding ? this.nanohtmlBindings.has(binding) : false if (isNanohtmlBinding || isNanohtmlRequireCall(tag.node)) { - let newPath = nanohtmlify(path.get('quasi'), state) - // If this template string is the only expression inside an arrow - // function, the `nanohtmlify` call may have introduced new variables - // inside its scope and forced it to become an arrow function with - // a block body. In that case if we replace the old `path`, it - // doesn't do anything. Instead we need to find the newly introduced - // `return` statement. - if (path.parentPath.isArrowFunctionExpression()) { - const statements = path.parentPath.get('body.body') - if (statements) { - path = statements.find((st) => st.isReturnStatement()) - } - } - path.replaceWith(newPath) - - // Remove the import or require() for the tag if it's no longer used - // anywhere. - if (binding) { - binding.dereference() - if (!binding.referenced) { - removeBindingImport(binding) - } + var expressions = quasi.node.expressions + var getexpr = function (i) { return expressions[i] } + var res = apply(path.node.tag, [ quasi.node.quasis.map(cooked) ].concat(expressions.map(expr)), getexpr) + if (res) { + path.replaceWith(res) + } else { + path.remove() } } } } } } + +function cooked (node) { return node.value.cooked } +function expr (val, i) { return transform.expr(i) } diff --git a/lib/browser.js b/lib/browser.js index 0bbf1d0..ab9e17f 100644 --- a/lib/browser.js +++ b/lib/browser.js @@ -1,79 +1,6 @@ var hyperx = require('hyperx') -var appendChild = require('./append-child') -var SVG_TAGS = require('./svg-tags') -var BOOL_PROPS = require('./bool-props') +var nanoHtmlCreateElement = require('./createElement') -var SVGNS = 'http://www.w3.org/2000/svg' -var XLINKNS = 'http://www.w3.org/1999/xlink' - -var COMMENT_TAG = '!--' - -function nanoHtmlCreateElement (tag, props, children) { - var el - - // If an svg tag, it needs a namespace - if (SVG_TAGS.indexOf(tag) !== -1) { - props.namespace = SVGNS - } - - // If we are using a namespace - var ns = false - if (props.namespace) { - ns = props.namespace - delete props.namespace - } - - // Create the element - if (ns) { - el = document.createElementNS(ns, tag) - } else if (tag === COMMENT_TAG) { - return document.createComment(props.comment) - } else { - el = document.createElement(tag) - } - - // Create the properties - for (var p in props) { - if (props.hasOwnProperty(p)) { - var key = p.toLowerCase() - var val = props[p] - // Normalize className - if (key === 'classname') { - key = 'class' - p = 'class' - } - // The for attribute gets transformed to htmlFor, but we just set as for - if (p === 'htmlFor') { - p = 'for' - } - // If a property is boolean, set itself to the key - if (BOOL_PROPS.indexOf(key) !== -1) { - if (val === 'true') val = key - else if (val === 'false') continue - } - // If a property prefers being set directly vs setAttribute - if (key.slice(0, 2) === 'on') { - el[p] = val - } else { - if (ns) { - if (p === 'xlink:href') { - el.setAttributeNS(XLINKNS, p, val) - } else if (/^xmlns($|:)/i.test(p)) { - // skip xmlns definitions - } else { - el.setAttributeNS(null, p, val) - } - } else { - el.setAttribute(p, val) - } - } - } - } - - appendChild(el, children) - return el -} - -module.exports = hyperx(nanoHtmlCreateElement, {comments: true}) +module.exports = hyperx(nanoHtmlCreateElement, { comments: true }) module.exports.default = module.exports module.exports.createElement = nanoHtmlCreateElement diff --git a/lib/browserify-transform.js b/lib/browserify-transform.js index 0b3af48..937813f 100644 --- a/lib/browserify-transform.js +++ b/lib/browserify-transform.js @@ -1,31 +1,25 @@ var convertSourceMap = require('convert-source-map') var transformAst = require('transform-ast') var through = require('through2') -var hyperx = require('hyperx') var acorn = require('acorn') var path = require('path') -var SVG_TAGS = require('./svg-tags') -var SUPPORTED_VIEWS = ['nanohtml', 'bel', 'yo-yo', 'choo', 'choo/html'] -var DELIM = '~!@|@|@!~' -var VARNAME = 'nanohtml' -var SVGNS = 'http://www.w3.org/2000/svg' -var XLINKNS = '"http://www.w3.org/1999/xlink"' -var BOOL_PROPS = require('./bool-props').reduce(function (o, key) { - o[key] = 1 - return o -}, {}) +var transform = require('./transform') -module.exports = function yoYoify (file, opts) { +var SUPPORTED_VIEWS = ['nanohtml', 'bel', 'yo-yo', 'choo/html'] + +module.exports = function (file, opts) { if (/\.json$/.test(file)) return through() var bufs = [] var viewVariables = [] var babelTemplateObjects = Object.create(null) return through(write, end) + function write (buf, enc, next) { bufs.push(buf) next() } + function end (cb) { var src = Buffer.concat(bufs).toString('utf8') var res @@ -42,13 +36,16 @@ module.exports = function yoYoify (file, opts) { this.push(res) this.push(null) } + function walk (node) { + var res + if (isSupportedView(node)) { if (node.arguments[0].value === 'bel' || node.arguments[0].value === 'choo/html' || node.arguments[0].value === 'nanohtml') { // html and choo/html have no other exports that may be used - node.edit.update('{}') + node.edit.update('{ createElement: require("' + path.join(node.arguments[0].value, '/lib/createElement") }')) } if (node.parent.type === 'VariableDeclarator') { viewVariables.push(node.parent.id.name) @@ -67,7 +64,8 @@ module.exports = function yoYoify (file, opts) { if (node.type === 'TemplateLiteral' && node.parent.tag) { var name = node.parent.tag.name || (node.parent.tag.object && node.parent.tag.object.name) if (viewVariables.indexOf(name) !== -1) { - processNode(node.parent, [ node.quasis.map(cooked) ].concat(node.expressions.map(expr))) + res = apply(name, [ node.quasis.map(cooked) ].concat(node.expressions.map(expr))) + node.parent.update(res) } } if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && viewVariables.indexOf(node.callee.name) !== -1) { @@ -78,7 +76,8 @@ module.exports = function yoYoify (file, opts) { // Emitted by Buble. template = node.arguments[0].elements.map(function (part) { return part.value }) expressions = node.arguments.slice(1).map(expr) - processNode(node, [ template ].concat(expressions)) + res = apply(node.callee.name, [ template ].concat(expressions)) + node.update(res) } else if (node.arguments[0] && node.arguments[0].type === 'Identifier') { // Detect transpiled template strings like: // html(_templateObject, {id: "test"}) @@ -86,7 +85,8 @@ module.exports = function yoYoify (file, opts) { var templateObject = babelTemplateObjects[node.arguments[0].name] template = templateObject.elements.map(function (part) { return part.value }) expressions = node.arguments.slice(1).map(expr) - processNode(node, [ template ].concat(expressions)) + res = apply(node.callee.name, [ template ].concat(expressions)) + node.update(res) // Remove the _taggedTemplateLiteral helper call templateObject.parent.edit.update('0') @@ -95,166 +95,31 @@ module.exports = function yoYoify (file, opts) { } } -function processNode (node, args) { - var resultArgs = [] - var argCount = 0 - var tagCount = 0 - - var needsAc = false - var needsSa = false - - var hx = hyperx(function (tag, props, children) { - var res = [] - - var elname = VARNAME + tagCount - tagCount++ - - if (tag === '!--') { - return DELIM + [elname, 'var ' + elname + ' = document.createComment(' + JSON.stringify(props.comment) + ')', null].join(DELIM) + DELIM - } - - // Whether this element needs a namespace - var namespace = props.namespace - if (!namespace && SVG_TAGS.indexOf(tag) !== -1) { - namespace = SVGNS - } - - // Create the element - if (namespace) { - res.push('var ' + elname + ' = document.createElementNS(' + JSON.stringify(namespace) + ', ' + JSON.stringify(tag) + ')') - } else { - res.push('var ' + elname + ' = document.createElement(' + JSON.stringify(tag) + ')') - } - - function addAttr (to, key, val) { - // Normalize className - if (key.toLowerCase() === '"classname"') { - key = '"class"' - } - // The for attribute gets transformed to htmlFor, but we just set as for - if (key === '"htmlFor"') { - key = '"for"' - } - // If a property is boolean, set itself to the key - if (BOOL_PROPS[key.slice(1, -1)]) { - if (val.slice(0, 9) === 'arguments') { - if (namespace) { - res.push('if (' + val + ' && ' + key + ') ' + to + '.setAttributeNS(null, ' + key + ', ' + key + ')') - } else { - res.push('if (' + val + ' && ' + key + ') ' + to + '.setAttribute(' + key + ', ' + key + ')') - } - return - } else { - if (val === 'true') val = key - else if (val === 'false') return - } - } - if (key.slice(1, 3) === 'on') { - res.push(to + '[' + key + '] = ' + val) - } else { - if (key === '"xlink:href"') { - res.push(to + '.setAttributeNS(' + XLINKNS + ', ' + key + ', ' + val + ')') - } else if (namespace && key.slice(0, 1) === '"') { - if (!/^xmlns($|:)/i.test(key.slice(1, -1))) { - // skip xmlns definitions - res.push(to + '.setAttributeNS(null, ' + key + ', ' + val + ')') - } - } else if (namespace) { - res.push('if (' + key + ') ' + to + '.setAttributeNS(null, ' + key + ', ' + val + ')') - } else if (key.slice(0, 1) === '"') { - res.push(to + '.setAttribute(' + key + ', ' + val + ')') - } else { - needsSa = true - res.push('sa(' + to + ', ' + key + ', ' + val + ')') - } - } - } - - // Add properties to element - Object.keys(props).forEach(function (key) { - var prop = props[key] - var ksrcs = getSourceParts(key) - var srcs = getSourceParts(prop) - var k, val - if (srcs) { - val = '' - srcs.forEach(function (src, index) { - if (src.arg) { - if (index > 0) val += ' + ' - if (src.before) val += JSON.stringify(src.before) + ' + ' - val += 'arguments[' + argCount + ']' - if (src.after) val += ' + ' + JSON.stringify(src.after) - resultArgs.push(src.arg) - argCount++ - } - }) - } else { - val = JSON.stringify(prop) - } - if (ksrcs) { - k = '' - ksrcs.forEach(function (src, index) { - if (src.arg) { - if (index > 0) val += ' + ' - if (src.before) val += JSON.stringify(src.before) + ' + ' - k += 'arguments[' + argCount + ']' - if (src.after) k += ' + ' + JSON.stringify(src.after) - resultArgs.push(src.arg) - argCount++ - } - }) - } else { - k = JSON.stringify(key) - } - addAttr(elname, k, val) - }) - - if (Array.isArray(children)) { - var childs = [] - children.forEach(function (child) { - var srcs = getSourceParts(child) - if (srcs) { - var src = srcs[0] - if (src.src) { - res.push(src.src) - } - if (src.name) { - childs.push(src.name) - } - if (src.arg) { - var argname = 'arguments[' + argCount + ']' - resultArgs.push(src.arg) - argCount++ - childs.push(argname) - } - } else { - childs.push(JSON.stringify(child)) - } - }) - if (childs.length > 0) { - needsAc = true - res.push('ac(' + elname + ', [' + childs.join(',') + '])') - } - } - - // Return delim'd parts as a child - return DELIM + [elname, res.join('\n'), null].join(DELIM) + DELIM - }, { comments: true }) - - // Run through hyperx - var res = hx.apply(null, args) - - // Pull out the final parts and wrap in a closure with arguments - var src = getSourceParts(res) - if (src && src[0].src) { - var params = resultArgs.join(',') - - node.edit.update('(function () {' + - (needsAc ? '\n var ac = require(\'' + path.resolve(__dirname, 'append-child.js').replace(/\\/g, '\\\\') + '\')' : '') + - (needsSa ? '\n var sa = require(\'' + path.resolve(__dirname, 'set-attribute.js').replace(/\\/g, '\\\\') + '\')' : '') + - '\n ' + src[0].src + '\n return ' + src[0].name + '\n }(' + params + '))') +var apply = transform.factory({ + arrayExpression: function (elements) { + return '[' + elements.join(',') + ']' + }, + objectExpression: function (properties) { + return '{' + properties.join(',') + '}' + }, + objectProperty: function (key, value, computed) { + return computed + ? ('[' + key + ']' + ':' + value) + : (key + ':' + value) + }, + stringLiteral: function (value) { + return JSON.stringify(value) + }, + callCreateElement (html, tag, props, children) { + return html + '.createElement(' + tag + ',' + props + ',' + children + ')' + }, + callObjectAssign (objects) { + return 'Object.assign(' + objects.join(',') + ')' + }, + stringConcat: function (a, b) { + return a + '+' + b } -} +}) function isSupportedView (node) { return (node.type === 'CallExpression' && @@ -269,32 +134,4 @@ function BabelTemplateDefinition (node) { } function cooked (node) { return node.value.cooked } -function expr (ex, idx) { - return DELIM + [null, null, ex.source()].join(DELIM) + DELIM -} -function getSourceParts (str) { - if (typeof str !== 'string') return false - if (str.indexOf(DELIM) === -1) return false - var parts = str.split(DELIM) - - var chunk = parts.splice(0, 5) - var arr = [{ - before: chunk[0], - name: chunk[1], - src: chunk[2], - arg: chunk[3], - after: chunk[4] - }] - while (parts.length > 0) { - chunk = parts.splice(0, 4) - arr.push({ - before: '', - name: chunk[0], - src: chunk[1], - arg: chunk[2], - after: chunk[3] - }) - } - - return arr -} +function expr (ex) { return transform.expr(ex.source()) } diff --git a/lib/createElement.js b/lib/createElement.js new file mode 100644 index 0000000..6654a74 --- /dev/null +++ b/lib/createElement.js @@ -0,0 +1,98 @@ +var appendChild = require('./append-child') +var SVG_TAGS = require('./svg-tags') +var BOOL_PROPS = require('./bool-props') + +var SVGNS = 'http://www.w3.org/2000/svg' +var XLINKNS = 'http://www.w3.org/1999/xlink' + +var COMMENT_TAG = '!--' + +function nanoHtmlCreateElement (tag, props, children) { + var el + var opts + + // If an svg tag, it needs a namespace + if (SVG_TAGS.indexOf(tag) !== -1) { + props.namespace = SVGNS + } + + // If we are using a namespace + var ns = false + if (props.namespace) { + ns = props.namespace + delete props.namespace + } + + // If `is` prop set then register custom element + if (props.is) { + if (typeof props.is === 'function') { + if (!window.customElements.get(props.is.tagName)) { + window.customElements.define(props.is.tagName, props.is, props.is.options) + } + opts = { is: props.is.tagName } + } else { + opts = { is: props.is } + } + } + + // Create the element + if (ns) { + el = document.createElementNS(ns, tag, opts) + } else if (tag === COMMENT_TAG) { + return document.createComment(props.comment) + } else if (typeof tag === 'function') { + if (!window.customElements.get(tag.tagName)) { + window.customElements.define(tag.tagName, tag, tag.options) + } + el = document.createElement(tag.tagName, opts) + } else { + el = document.createElement(tag, opts) + } + + // Create the properties + for (var p in props) { + if (props.hasOwnProperty(p)) { + var key = p.toLowerCase() + var val = props[p] + // Normalize className + if (key === 'classname') { + key = 'class' + p = 'class' + } + // The for attribute gets transformed to htmlFor, but we just set as for + if (p === 'htmlFor') { + p = 'for' + } + // If `is` attribute was set on createElement then skip + if (key === 'is' && opts && opts.is) { + continue + } + // If a property is boolean, set itself to the key + if (BOOL_PROPS.indexOf(key) !== -1) { + if (val === 'false' || val === false) continue + else if (val === 'true' || val === true) val = key + } + // If a property prefers being set directly vs setAttribute + if (key.slice(0, 2) === 'on') { + el[p] = val + } else { + if (ns) { + if (p === 'xlink:href') { + el.setAttributeNS(XLINKNS, p, val) + } else if (/^xmlns($|:)/i.test(p)) { + // skip xmlns definitions + } else { + el.setAttributeNS(null, p, val) + } + } else { + el.setAttribute(p, val) + } + } + } + } + + appendChild(el, children) + return el +} + +module.exports = nanoHtmlCreateElement diff --git a/lib/server.js b/lib/server.js index 4da8277..95749d7 100644 --- a/lib/server.js +++ b/lib/server.js @@ -52,7 +52,7 @@ function handleValue (value) { // Ignore event handlers. `onclick=${(e) => doSomething(e)}` // will become. `onclick=""` - if (typeof value === 'function') return '' + if (typeof value === 'function') return value.tagName ? value.tagName : '' if (value === null || value === undefined) return '' if (value.__encoded) return value diff --git a/lib/set-attribute.js b/lib/set-attribute.js deleted file mode 100644 index 79fccc3..0000000 --- a/lib/set-attribute.js +++ /dev/null @@ -1,21 +0,0 @@ -module.exports = function nanohtmlSetAttribute (el, attr, value) { - if (typeof attr === 'object') { - for (var i in attr) { - if (attr.hasOwnProperty(i)) { - nanohtmlSetAttribute(el, i, attr[i]) - } - } - return - } - if (!attr) return - if (attr === 'className') attr = 'class' - if (attr === 'htmlFor') attr = 'for' - if (attr.slice(0, 2) === 'on') { - el[attr] = value - } else { - // assume a boolean attribute if the value === true - // no need to do typeof because "false" would've caused an early return - if (value === true) value = attr - el.setAttribute(attr, value) - } -} diff --git a/lib/transform.js b/lib/transform.js new file mode 100644 index 0000000..a75ab02 --- /dev/null +++ b/lib/transform.js @@ -0,0 +1,104 @@ +var hyperx = require('hyperx') + +var EXPR_DELIM = '~!@|@|@!~' +var CEXPR_DELIM = '~!#|#|#!~' + +module.exports.expr = expr +module.exports.factory = factory + +function expr (ex) { return EXPR_DELIM + ex + EXPR_DELIM } +function unexpr (ex) { return ex.split(EXPR_DELIM)[1] } +function isexpr (ex) { return typeof ex === 'string' && ex.indexOf(EXPR_DELIM) !== -1 } + +function cexpr (cex) { return CEXPR_DELIM + cex + CEXPR_DELIM } +function uncexpr (cex) { return cex.split(CEXPR_DELIM)[1] } +function iscexpr (cex) { return typeof cex === 'string' && cex.indexOf(CEXPR_DELIM) !== -1 } + +function wrap (value) { return { __wrapper__: value } } +function unwrap (wrapper) { return wrapper ? wrapper.__wrapper__ : undefined } +function iswrapper (wrapper) { return typeof wrapper === 'object' && wrapper.__wrapper__ !== undefined } + +function concat (a, b) { + var aexpr + var bexpr + + if (iscexpr(a)) aexpr = a // c-expression + else if (isexpr(a)) aexpr = cexpr(JSON.stringify(a)) // expression + else aexpr = a && cexpr(JSON.stringify(a)) // literal + + if (iscexpr(b)) aexpr = b + else if (isexpr(b)) bexpr = cexpr(JSON.stringify(b)) + else bexpr = b && cexpr(JSON.stringify(b)) + + if (!aexpr) return bexpr + if (!bexpr) return aexpr + + return cexpr('["+",' + uncexpr(aexpr) + ',' + uncexpr(bexpr) + ']') +} + +function factory (impl) { + return function (html, args, getexpr) { + var result = apply(html, args, getexpr) + if (result) return unwrap(result) + } + + function apply (html, args, _getexpr) { + function getexpr (ex) { return _getexpr ? _getexpr(unexpr(ex)) : unexpr(ex) } + + function parse (cex) { + if (iscexpr(cex)) return parse(JSON.parse(uncexpr(cex))) + if (isexpr(cex)) return getexpr(cex) + if (!Array.isArray(cex)) return impl.stringLiteral(cex) + + var op = cex[0] + var left = cex[1] + var right = cex[2] + + if (op === '+') { + return impl.stringConcat(parse(left), parse(right)) + } + } + + function resolve (val) { + if (iscexpr(val)) return parse(val) + if (iswrapper(val)) return unwrap(val) + if (isexpr(val)) return getexpr(val) + return impl.stringLiteral(val) + } + + function h (tag, props, children) { + tag = isexpr(tag) ? getexpr(tag) : impl.stringLiteral(tag) + + props = Object.keys(props).map(function (key) { + if (isexpr(key)) { + if (key === props[key]) { + // spread props
+ return { object: resolve(props[key]) } + } else { + // expr props
+ return { object: impl.objectExpression([impl.objectProperty(resolve(key), resolve(props[key]), true)]) } + } + } else { + return { property: impl.objectProperty(resolve(key), resolve(props[key])) } + } + }) + + if (props.some(function (prop) { return prop.object })) { + props = props.map(function (prop) { return prop.property ? impl.objectExpression([prop.property]) : prop.object }) + props = impl.callObjectAssign(props) + } else { + props = props.map(function (prop) { return prop.property }) + props = impl.objectExpression(props) + } + + children = children || [] + children = children.map(resolve) + children = impl.arrayExpression(children) + + return wrap(impl.callCreateElement(html, tag, props, children)) + } + + var hx = hyperx(h, { concat: concat, comments: true }) + return hx.apply(null, args) + } +} diff --git a/package.json b/package.json index 914f1a3..ab6905e 100644 --- a/package.json +++ b/package.json @@ -23,13 +23,10 @@ }, "dependencies": { "acorn": "^5.2.1", - "camel-case": "^3.0.0", "convert-source-map": "^1.5.1", "hyperx": "^2.3.2", - "is-boolean-attribute": "0.0.1", "nanoassert": "^1.1.0", "nanobench": "^2.1.0", - "normalize-html-whitespace": "^0.2.0", "through2": "^2.0.3", "transform-ast": "^2.4.0" }, diff --git a/tests/babel/build.js b/tests/babel/build.js index 9e8a18e..63cc0d4 100644 --- a/tests/babel/build.js +++ b/tests/babel/build.js @@ -1,17 +1,15 @@ const browserify = require('browserify') + const nanohtml = require('../../') browserify(require.resolve('../browser')) + .require('./lib/createElement.js', { expose: 'nanohtml/lib/createElement' }) .transform('aliasify', { aliases: { '../../': 'nanohtml' } }) .transform('babelify', { plugins: [ - [nanohtml, { - // Explicitly set these, because `nanohtml` can't be resolved - appendChildModule: require.resolve('../../lib/append-child'), - setAttributeModule: require.resolve('../../lib/set-attribute') - }] + nanohtml ] }) .bundle() diff --git a/tests/babel/fixtures/arrowFunctions.expected.js b/tests/babel/fixtures/arrowFunctions.expected.js index 4af8e99..e971dc8 100644 --- a/tests/babel/fixtures/arrowFunctions.expected.js +++ b/tests/babel/fixtures/arrowFunctions.expected.js @@ -1,11 +1,8 @@ -var _appendChild = require('nanohtml/lib/append-child'); +var html = { + 'createElement': require('nanohtml/lib/createElement') +}; -const component = () => { - var _h, _div; - _div = document.createElement('div'), _appendChild(_div, ['\n ', (_h = document.createElement('h1'), _appendChild(_h, [' hello world ']), _h), '\n ', list.map(x => { - var _span; - - _span = document.createElement('span'), _span.setAttribute('style', 'background-color: red; margin: 10px;'), _appendChild(_span, [' ', x, ' ']), _span; - }), '\n ']), _div; -}; // https://github.com/goto-bus-stop/babel-plugin-yo-yoify/issues/14 \ No newline at end of file +const component = () => html.createElement('div', {}, ['\n ', html.createElement('h1', {}, [' hello world ']), '\n ', list.map(x => html.createElement('span', { + 'style': 'background-color: red; margin: 10px;' +}, [' ', x, ' '])), '\n ']); // https://github.com/goto-bus-stop/babel-plugin-yo-yoify/issues/14 \ No newline at end of file diff --git a/tests/babel/fixtures/booleanAttr.expected.js b/tests/babel/fixtures/booleanAttr.expected.js index e7296f2..5ce0b04 100644 --- a/tests/babel/fixtures/booleanAttr.expected.js +++ b/tests/babel/fixtures/booleanAttr.expected.js @@ -1,5 +1,14 @@ -var _input, _input2, _button; +var html = { + 'createElement': require('nanohtml/lib/createElement') +}; -_input = document.createElement('input'), _input.setAttribute('autofocus', 'autofocus'), _input; -_input2 = document.createElement('input'), true && _input2.setAttribute('checked', 'checked'), _input2; -_button = document.createElement('button'), someVariable && _button.setAttribute('disabled', 'disabled'), _button; + +html.createElement('input', { + 'autofocus': 'autofocus' +}, []); +html.createElement('input', { + 'checked': true +}, []); +html.createElement('button', { + 'disabled': someVariable +}, []); \ No newline at end of file diff --git a/tests/babel/fixtures/combinedAttr.expected.js b/tests/babel/fixtures/combinedAttr.expected.js index e28449d..20af06a 100644 --- a/tests/babel/fixtures/combinedAttr.expected.js +++ b/tests/babel/fixtures/combinedAttr.expected.js @@ -1,4 +1,8 @@ -var _div, - _appendChild = require('nanohtml/lib/append-child'); +var html = { + 'createElement': require('nanohtml/lib/createElement') +}; -_div = document.createElement('div'), _div.setAttribute('id', 'a' + String(1) + ' b' + String(2) + ''), _appendChild(_div, ['\n']), _div; \ No newline at end of file + +html.createElement('div', { + 'id': 'a' + 1 + ' b' + 2 +}, ['\n']); \ No newline at end of file diff --git a/tests/babel/fixtures/comment.expected.js b/tests/babel/fixtures/comment.expected.js index 79abd1e..a75bbfb 100644 --- a/tests/babel/fixtures/comment.expected.js +++ b/tests/babel/fixtures/comment.expected.js @@ -1,4 +1,8 @@ -var _div, - _appendChild = require('nanohtml/lib/append-child'); +var html = { + 'createElement': require('yo-yo/lib/createElement') +}; -_div = document.createElement('div'), _appendChild(_div, ['\n ', document.createComment(' important comment text '), '\n ']), _div; \ No newline at end of file + +html.createElement('div', {}, ['\n ', html.createElement('!--', { + 'comment': ' important comment text ' +}, []), '\n ']); \ No newline at end of file diff --git a/tests/babel/fixtures/dynamicAttr.expected.js b/tests/babel/fixtures/dynamicAttr.expected.js index 371afca..45f99ba 100644 --- a/tests/babel/fixtures/dynamicAttr.expected.js +++ b/tests/babel/fixtures/dynamicAttr.expected.js @@ -1,18 +1,28 @@ -var _halp, - _str, - _lol, - _abc, - _appendChild = require('nanohtml/lib/append-child'), - _setAttribute = require('nanohtml/lib/set-attribute'); +var html = { + 'createElement': require('nanohtml/lib/createElement') +}; + var handler = isTouchDevice ? 'ontouchstart' : 'onmousedown'; -_halp = document.createElement('div'), _halp.setAttribute('id', 'halp'), _setAttribute(_halp, handler, () => {}), _appendChild(_halp, ['\n']), _halp; +html.createElement('div', Object.assign({ + 'id': 'halp' +}, { + [handler]: () => {} +}), ['\n']); var className = 'className'; -_str = document.createElement('div'), _str.setAttribute('id', 'str'), _setAttribute(_str, className, 'blub'), _appendChild(_str, ['\n']), _str; +html.createElement('div', Object.assign({ + 'id': 'str' +}, { + [className]: 'blub' +}), ['\n']); -var x = 'disabled'; -_lol = document.createElement('button'), _setAttribute(_lol, x, x), _lol.setAttribute('id', 'lol'), _appendChild(_lol, ['\n']), _lol; -x = ''; -_abc = document.createElement('button'), _setAttribute(_abc, x, x), _abc.setAttribute('id', 'abc'), _abc; \ No newline at end of file +var x = { disabled: 'disabled' }; +html.createElement('button', Object.assign(x, { + 'id': 'lol' +}), ['\n']); +x = {}; +html.createElement('button', Object.assign(x, { + 'id': 'abc' +}), []); \ No newline at end of file diff --git a/tests/babel/fixtures/dynamicAttr.js b/tests/babel/fixtures/dynamicAttr.js index 0eb3f10..0f2f8a0 100644 --- a/tests/babel/fixtures/dynamicAttr.js +++ b/tests/babel/fixtures/dynamicAttr.js @@ -11,9 +11,9 @@ html`
` -var x = 'disabled' +var x = { disabled: 'disabled' } html` `\n const el2 = html``' // eslint-disable-line + var src = 'var html = require(\'nanohtml\')\n var a = \'testa\'\n var b = \'testb\'\n html`
`' // eslint-disable-line fs.writeFileSync(FIXTURE, src) var b = browserify(FIXTURE, { transform: path.join(__dirname, '../../') }) + b.require('./lib/createElement.js', { expose: 'nanohtml/lib/createElement' }) b.bundle(function (err, src) { fs.unlinkSync(FIXTURE) t.ifError(err, 'no error') var result = src.toString() - t.ok(result.indexOf('const el1 = (function () {') !== -1, 'converted el1 to a iife') - t.ok(result.indexOf('const el2 = (function () {') !== -1, 'converted el1 to a iife') + t.ok(result.indexOf('{"class":a+" "+b}') !== -1, 'set with both variables') t.end() }) }) @@ -133,10 +80,11 @@ test('emits error for syntax error', function (t) { var src = 'var html = require(\'nanohtml\')\n module.exports = function (data) {\n var className = (\'test\' + ) // <--- HERE\'S A SYNTAX ERROR\n return html`
\n

${data}

\n
`\n }' // eslint-disable-line fs.writeFileSync(FIXTURE, src) var b = browserify(FIXTURE, { - browserField: false, transform: path.join(__dirname, '../../') }) + // don't b.require createElement, it will hang this test b.bundle(function (err, src) { + fs.unlinkSync(FIXTURE) t.ok(err) t.end() }) @@ -149,6 +97,7 @@ test('works with newer js', function (t) { var b = browserify(FIXTURE, { transform: path.join(__dirname, '../../') }) + b.require('./lib/createElement.js', { expose: 'nanohtml/lib/createElement' }) b.bundle(function (err, src) { fs.unlinkSync(FIXTURE) t.ifError(err, 'no error') @@ -163,6 +112,7 @@ test('boolean attribute expression', function (t) { var b = browserify(FIXTURE, { transform: path.join(__dirname, '../../') }) + b.require('./lib/createElement.js', { expose: 'nanohtml/lib/createElement' }) b.bundle(function (err, src) { fs.unlinkSync(FIXTURE) t.ifError(err, 'no error') @@ -185,10 +135,11 @@ test('babel-compiled template literals', function (t) { path.join(__dirname, '../../') ] }) + b.require('./lib/createElement.js', { expose: 'nanohtml/lib/createElement' }) b.bundle(function (err, src) { fs.unlinkSync(FIXTURE) t.ifError(err) - t.ok(src.indexOf('document.createElement("div")') !== -1, 'created a tag') + t.ok(src.indexOf('html.createElement("div",{"class":"whatever "+abc},[xyz])') !== -1, 'created a tag') t.ok(src.indexOf('): HTMLElement; + export function createElement (tag: string | typeof HTMLElement, attributes: any, children: Array): HTMLElement; }