wasm-metering
Version:
injects metering into webassembly binaries
416 lines (359 loc) • 13 kB
JavaScript
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()
}
}
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
}
const firstArg = node.arguments[0]
// Not a `require('module')` call
if (!firstArg || !t.isStringLiteral(firstArg)) {
return false
}
const importFrom = firstArg.value
return nanohtmlModuleNames.indexOf(importFrom) !== -1
}
return {
pre () {
this.nanohtmlBindings = new Set()
},
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)
})
}
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)])
})
}
}
}
},
/**
* Collect nanohtml variable names and remove their imports if necessary.
*/
ImportDeclaration (path, state) {
const importFrom = path.get('source').node.value
if (nanohtmlModuleNames.indexOf(importFrom) !== -1) {
const specifier = path.get('specifiers')[0]
if (specifier.isImportDefaultSpecifier()) {
this.nanohtmlBindings.add(path.scope.getBinding(specifier.node.local.name))
}
}
},
CallExpression (path, state) {
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 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)
}
}
}
}
}
}
}