jsx-dom-runtime
Version:
A tiny 500-byte library for JSX syntax templates targeting the DOM. Supports HTML, SVG, and MathML tags
490 lines (478 loc) • 21.9 kB
JavaScript
'use strict';
var t = require('@babel/types');
var helperValidatorIdentifier = require('@babel/helper-validator-identifier');
const $stringLiteral = value => ({
type: 'StringLiteral',
value
});
const $identifier = name => ({
type: 'Identifier',
name
});
const $jsxIdentifier = name => ({
type: 'JSXIdentifier',
name
});
const $jsxExpressionContainer = expression => ({
type: 'JSXExpressionContainer',
expression
});
const $objectProperty = (key, value) => ({
type: 'ObjectProperty',
key,
value,
computed: false,
shorthand: false,
decorators: null
});
const $children = elements => 1 === elements.length ? elements[0] : {
type: 'ArrayExpression',
elements
};
const $pureAnnotation = () => [{
type: 'CommentBlock',
value: '#__PURE__'
}];
const $expressionStatement = expression => ({
type: 'ExpressionStatement',
expression
});
class ImportSpec {
#path;
#cache = new Map();
#specifiers = [];
constructor(path) {
this.#path = path;
}
add(importName) {
if (this.#cache.has(importName)) {
return this.#cache.get(importName);
}
if (0 === this.#specifiers.length) {
this.#path.unshiftContainer('body', {
type: 'ImportDeclaration',
specifiers: this.#specifiers,
source: $stringLiteral('jsx-dom-runtime')
});
}
const local = $identifier('_' + importName);
this.#cache.set(importName, local);
this.#specifiers.push({
type: 'ImportSpecifier',
local,
imported: $identifier(importName)
});
return local;
}
}
const events = ['copy', 'cut', 'paste', 'compositionend', 'compositionstart', 'compositionupdate', 'change', 'reset', 'invalid', 'load', 'error', 'select', 'selectionchange', 'focus', 'blur', 'beforeinput', 'input', 'submit', 'formdata', 'keydown', 'keypress', 'keyup', 'abort', 'canplay', 'canplaythrough', 'durationchange', 'emptied', 'ended', 'loadeddata', 'loadedmetadata', 'loadstart', 'pause', 'play', 'playing', 'progress', 'ratechange', 'seeked', 'seeking', 'stalled', 'suspend', 'timeupdate', 'volumechange', 'waiting', 'waitingforkey', 'encrypted', 'auxclick', 'click', 'contextmenu', 'dblclick', 'mousedown', 'mouseenter', 'mouseleave', 'mousemove', 'mouseout', 'mouseover', 'mouseup', 'drag', 'dragend', 'dragenter', 'dragleave', 'dragover', 'dragstart', 'drop', 'dragexit', 'touchcancel', 'touchend', 'touchmove', 'touchstart', 'pointerdown', 'pointermove', 'pointerup', 'pointercancel', 'pointerenter', 'pointerleave', 'pointerover', 'pointerout', 'gotpointercapture', 'lostpointercapture', 'scroll', 'scrollend', 'wheel', 'animationstart', 'animationend', 'animationiteration', 'animationcancel', 'transitionend', 'transitionstart', 'transitioncancel', 'transitionrun', 'enterpictureinpicture', 'leavepictureinpicture', 'resize', 'beforetoggle', 'toggle', 'cancel', 'close', 'fullscreenchange', 'fullscreenerror', 'cuechange', 'contentvisibilityautostatechange', 'command'];
const DOMEvents = new Set(events.map(e => 'on' + e));
const eventTypes = new Set([...events, 'focusin', 'focusout', 'webglcontextlost', 'webglcontextrestored', 'webglcontextcreationerror']);
const attributes = new Set(['tabindex', 'inputmode', 'referrerpolicy', 'enterkeyhint', 'maxlength', 'minlength', 'itemprop', 'itemtype', 'itemid', 'itemref', 'accesskey', 'elementtiming', 'usemap', 'fetchpriority', 'controlslist', 'dirname', 'formtarget', 'formmethod', 'formenctype', 'formaction', 'datetime', 'colspan', 'rowspan', 'srcset', 'shadowrootmode', 'closedby']);
const booleanAttributes = new Set(['async', 'autofocus', 'autocomplete', 'autoplay', 'attributionsrc', 'controls', 'checked', 'crossorigin', 'capture', 'defer', 'disabled', 'contenteditable', 'formnovalidate', 'readonly', 'multiple', 'loop', 'required', 'hidden', 'open', 'selected', 'nomodule', 'noshade', 'novalidate', 'playsinline', 'reversed', 'inert', 'disablepictureinpicture', 'disableremoteplayback', 'popover', 'itemscope', 'declare', 'moz-opaque', 'ismap', 'shadowrootclonable', 'shadowrootdelegatesfocus', 'shadowrootserializable']);
const enumerated = new Set(['aria-atomic', 'aria-braillelabel', 'aria-brailleroledescription', 'aria-busy', 'aria-controls', 'aria-current', 'aria-describedby', 'aria-description', 'aria-details', 'aria-disabled', 'aria-dropeffect', 'aria-errormessage', 'aria-flowto', 'aria-grabbed', 'aria-haspopup', 'aria-hidden', 'aria-invalid', 'aria-keyshortcuts', 'aria-label', 'aria-labelledby', 'aria-live', 'aria-owns', 'aria-relevant', 'aria-roledescription', 'aria-autocomplete', 'aria-checked', 'aria-expanded', 'aria-level', 'aria-modal', 'aria-multiline', 'aria-multiselectable', 'aria-orientation', 'aria-placeholder', 'aria-pressed', 'aria-readonly', 'aria-required', 'aria-selected', 'aria-sort', 'aria-valuemax', 'aria-valuemin', 'aria-valuenow', 'aria-valuetext', 'aria-activedescendant', 'aria-colcount', 'aria-colindex', 'aria-colindextext', 'aria-colspan', 'aria-posinset', 'aria-rowcount', 'aria-rowindex', 'aria-rowindextext', 'aria-rowspan', 'aria-setsize', 'draggable', 'spellcheck', 'writingsuggestions']);
const svgDOMAttributes = new Map(Object.entries({
accentHeight: 'accent-height',
alignmentBaseline: 'alignment-baseline',
arabicForm: 'arabic-form',
baselineShift: 'baseline-shift',
capHeight: 'cap-height',
clipPath: 'clip-path',
clipRule: 'clip-rule',
colorInterpolation: 'color-interpolation',
colorInterpolationFilters: 'color-interpolation-filters',
colorProfile: 'color-profile',
colorRendering: 'color-rendering',
dominantBaseline: 'dominant-baseline',
enableBackground: 'enable-background',
fillOpacity: 'fill-opacity',
fillRule: 'fill-rule',
floodColor: 'flood-color',
floodOpacity: 'flood-opacity',
fontFamily: 'font-family',
fontSize: 'font-size',
fontSizeAdjust: 'font-size-adjust',
fontStretch: 'font-stretch',
fontStyle: 'font-style',
fontVariant: 'font-variant',
fontWeight: 'font-weight',
glyphName: 'glyph-name',
glyphOrientationHorizontal: 'glyph-orientation-horizontal',
glyphOrientationVertical: 'glyph-orientation-vertical',
horizAdvX: 'horiz-adv-x',
horizOriginX: 'horiz-origin-x',
imageRendering: 'image-rendering',
letterSpacing: 'letter-spacing',
lightingColor: 'lighting-color',
markerEnd: 'marker-end',
markerMid: 'marker-mid',
markerStart: 'marker-start',
overlinePosition: 'overline-position',
overlineThickness: 'overline-thickness',
paintOrder: 'paint-order',
panose1: 'panose-1',
pointerEvents: 'pointer-events',
renderingIntent: 'rendering-intent',
shapeRendering: 'shape-rendering',
stopColor: 'stop-color',
stopOpacity: 'stop-opacity',
strikethroughPosition: 'strikethrough-position',
strikethroughThickness: 'strikethrough-thickness',
strokeDasharray: 'stroke-dasharray',
strokeDashoffset: 'stroke-dashoffset',
strokeLinecap: 'stroke-linecap',
strokeLinejoin: 'stroke-linejoin',
strokeMiterlimit: 'stroke-miterlimit',
strokeOpacity: 'stroke-opacity',
strokeWidth: 'stroke-width',
textAnchor: 'text-anchor',
textDecoration: 'text-decoration',
textRendering: 'text-rendering',
underlinePosition: 'underline-position',
underlineThickness: 'underline-thickness',
unicodeBidi: 'unicode-bidi',
unicodeRange: 'unicode-range',
unitsPerEm: 'units-per-em',
vAlphabetic: 'v-alphabetic',
vHanging: 'v-hanging',
vIdeographic: 'v-ideographic',
vMathematical: 'v-mathematical',
vectorEffect: 'vector-effect',
vertAdvY: 'vert-adv-y',
vertOriginX: 'vert-origin-x',
vertOriginY: 'vert-origin-y',
wordSpacing: 'word-spacing',
writingMode: 'writing-mode',
xHeight: 'x-height',
xlinkActuate: 'xlink:actuate',
xlinkArcrole: 'xlink:arcrole',
xlinkHref: 'href',
xlinkRole: 'xlink:role',
xlinkShow: 'xlink:show',
xlinkTitle: 'xlink:title',
xlinkType: 'xlink:type',
xmlBase: 'xml:base',
xmlLang: 'xml:lang',
xmlSpace: 'xml:space'
}));
const jsxNode = new Set(['JSXElement', 'JSXFragment']);
const charCode = new Set([36, 95]);
for (let i = 65; i <= 90; i++) charCode.add(i);
const cache$1 = new WeakMap();
const getObjectProperties = element => {
if (cache$1.has(element)) {
return cache$1.get(element);
}
const properties = [];
element.attributes.push({
type: 'JSXAttribute',
name: $jsxIdentifier('$'),
value: $jsxExpressionContainer({
type: 'ObjectExpression',
properties
})
});
cache$1.set(element, properties);
return properties;
};
const eventListener = (element, key, value) => {
if ('JSXExpressionContainer' !== value?.type) {
return;
}
const properties = getObjectProperties(element);
const name = key.name.toLowerCase();
properties.push($objectProperty(eventTypes.has(name) ? $identifier(name) : helperValidatorIdentifier.isIdentifierName(key.name) ? $identifier(key.name) : $stringLiteral(key.name), value.expression));
};
const cache = new WeakMap();
const isRef = i => 'ObjectProperty' === i.type && 'Identifier' === i.key.type && 'ref' === i.key.name;
const getRef = element => {
if (cache.has(element)) {
return cache.get(element);
}
const funcRef = {
type: 'ArrowFunctionExpression',
params: [$identifier('e')],
body: null,
async: false,
expression: false
};
element.attributes.unshift({
type: 'JSXAttribute',
name: $jsxIdentifier('ref'),
value: $jsxExpressionContainer(funcRef)
});
cache.set(element, funcRef);
return funcRef;
};
const createDirective = (element, expression) => {
const funcRef = getRef(element);
if (null === funcRef.body) {
funcRef.body = expression;
return;
}
const bodyType = funcRef.body.type;
if ('AssignmentExpression' === bodyType || 'CallExpression' === bodyType) {
funcRef.body = {
type: 'BlockStatement',
body: [$expressionStatement(funcRef.body), $expressionStatement(expression)],
directives: []
};
} else if ('BlockStatement' === bodyType) {
funcRef.body.body.push($expressionStatement(expression));
}
};
const convertJSXNamespacedName = node => $stringLiteral(node.namespace.name + ':' + node.name.name);
const convertJSXAttrValue = value => {
const expression = null == value ? {
type: 'BooleanLiteral',
value: true
} : 'JSXExpressionContainer' === value.type ? value.expression : value;
if ('StringLiteral' === expression.type) {
expression.value = expression.value.replace(/\n\s+/g, ' ');
}
return expression;
};
const buildProps = node => ({
type: 'ObjectExpression',
properties: node.openingElement.attributes.map(attr => {
if ('JSXSpreadAttribute' === attr.type) {
return {
type: 'SpreadElement',
argument: attr.argument
};
}
return $objectProperty('JSXNamespacedName' === attr.name.type ? convertJSXNamespacedName(attr.name) : helperValidatorIdentifier.isIdentifierName(attr.name.name) ? $identifier(attr.name.name) : $stringLiteral(attr.name.name), convertJSXAttrValue(attr.value));
})
});
const convertJSXIdentifier = node => {
if ('JSXIdentifier' === node.type) {
return $identifier(node.name);
}
return {
type: 'MemberExpression',
object: convertJSXIdentifier(node.object),
property: $identifier(node.property.name),
computed: false,
optional: null
};
};
const svgTags = new Set(['altGlyph', 'altGlyphDef', 'altGlyphItem', 'animate', 'animateColor', 'animateMotion', 'animateTransform', 'circle', 'clipPath', 'color-profile', 'cursor', 'defs', 'desc', 'ellipse', 'feBlend', 'feColorMatrix', 'feComponentTransfer', 'feComposite', 'feConvolveMatrix', 'feDiffuseLighting', 'feDisplacementMap', 'feDistantLight', 'feFlood', 'feFuncA', 'feFuncB', 'feFuncG', 'feFuncR', 'feGaussianBlur', 'feImage', 'feMerge', 'feMergeNode', 'feMorphology', 'feOffset', 'fePointLight', 'feSpecularLighting', 'feSpotLight', 'feTile', 'feTurbulence', 'filter', 'font', 'font-face', 'font-face-format', 'font-face-name', 'font-face-src', 'font-face-uri', 'foreignObject', 'g', 'glyph', 'glyphRef', 'hkern', 'image', 'line', 'linearGradient', 'marker', 'mask', 'metadata', 'missing-glyph', 'mpath', 'path', 'pattern', 'polygon', 'polyline', 'radialGradient', 'rect', 'set', 'stop', 'svg', 'switch', 'symbol', 'text', 'textPath', 'tref', 'tspan', 'use', 'view', 'vkern', 'discard', 'mesh', 'meshgradient', 'meshpatch', 'meshrow', 'solidcolor']);
const voidHTMLTags = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'];
const allHTMLTags = [...voidHTMLTags, 'a', 'abbr', 'address', 'article', 'aside', 'audio', 'b', 'bdi', 'bdo', 'blockquote', 'body', 'button', 'canvas', 'caption', 'cite', 'code', 'colgroup', 'data', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'div', 'dl', 'dt', 'em', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'html', 'i', 'iframe', 'ins', 'kbd', 'label', 'legend', 'li', 'main', 'map', 'mark', 'menu', 'menuitem', 'meter', 'nav', 'noscript', 'object', 'ol', 'optgroup', 'option', 'output', 'p', 'picture', 'pre', 'progress', 'q', 'rb', 'rp', 'rt', 'rtc', 'ruby', 's', 'samp', 'script', 'search', 'section', 'select', 'slot', 'small', 'span', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'title', 'tr', 'u', 'ul', 'var', 'video', 'fencedframe', 'selectedcontent', 'acronym', 'applet', 'basefont', 'bgsound', 'big', 'blink', 'center', 'noframes', 'tt', 'strike', 'xmp', 'isindex', 'keygen'];
const htmlTags = new Set(allHTMLTags);
const mathmlTags = new Set(['annotation', 'annotation-xml', 'maction', 'math', 'merror', 'mfrac', 'mi', 'mmultiscripts', 'mn', 'mo', 'mover', 'mpadded', 'mphantom', 'mprescripts', 'mroot', 'mrow', 'ms', 'mspace', 'msqrt', 'mstyle', 'msub', 'msubsup', 'msup', 'mtable', 'mtd', 'mtext', 'mtr', 'munder', 'munderover', 'semantics', 'mfenced', 'menclose', 'mlabeledtr', 'maligngroup', 'malignmark']);
const htmlDOMAttributes = new Map(Object.entries({
acceptCharset: 'accept-charset',
className: 'class',
httpEquiv: 'http-equiv',
htmlFor: 'for',
xlinkHref: 'href'
}));
const opts = {
name: '_'
};
const isFunctionComponent = name => charCode.has(name.name.charCodeAt(0));
const isChildren = node => 'ObjectProperty' === node.type && 'Identifier' === node.key.type && 'children' === node.key.name;
let nsMap;
let importSpec;
const jsxTransform = {
name: 'jsx-dom-runtime/babel-plugin-transform-jsx',
visitor: {
Program(path) {
nsMap = new WeakMap();
importSpec = new ImportSpec(path);
},
JSXFragment(path) {
const children = t.react.buildChildren(path.node);
if (jsxNode.has(path.parent.type)) {
if (children.length > 0) {
path.replaceWith($children(children));
} else {
path.remove();
}
} else {
path.replaceWith({
type: 'CallExpression',
callee: importSpec.add('Fragment'),
arguments: children.length > 0 ? [$children(children)] : [],
leadingComments: $pureAnnotation()
});
}
},
JSXElement: {
enter(path) {
const name = path.node.openingElement.name;
if ('JSXNamespacedName' === name.type) {
return;
}
if ('JSXMemberExpression' === name.type || isFunctionComponent(name)) {
const props = buildProps(path.node);
const children = t.react.buildChildren(path.node);
if (children.length > 0) {
props.properties.push($objectProperty($identifier('children'), $children(children)));
}
path.replaceWith({
type: 'CallExpression',
callee: convertJSXIdentifier(name),
arguments: [props]
});
} else if (svgTags.has(name.name)) {
nsMap.set(path, 'svgNs');
} else if (mathmlTags.has(name.name)) {
nsMap.set(path, 'mathmlNs');
}
},
exit(path) {
const name = path.node.openingElement.name;
const props = buildProps(path.node);
const refs = props.properties.filter(isRef);
const childrenContent = t.react.buildChildren(path.node);
const childrenProps = props.properties.findLast(isChildren);
const children = childrenContent.length > 0 ? childrenContent : null != childrenProps ? [childrenProps.value] : [];
const args = ['JSXIdentifier' === name.type ? $stringLiteral(name.name) : convertJSXNamespacedName(name), props];
if (null != childrenProps) {
props.properties = props.properties.filter(i => !isChildren(i));
}
if (children.length > 0) {
args.push($children(children));
}
if (refs.length > 1) {
const ref = refs[0];
ref.value = {
type: 'ArrayExpression',
elements: refs.map(i => i.value)
};
props.properties = props.properties.filter(i => !isRef(i));
props.properties.push(ref);
}
const noNs = props.properties.every(i => !t.isIdentifier(i.key, opts));
if (noNs) {
const importName = nsMap.get(path) ?? nsMap.get(path.findParent(p => jsxNode.has(p.node.type)));
if (undefined !== importName) {
props.properties.push($objectProperty($identifier('_'), importSpec.add(importName)));
}
}
path.replaceWith({
type: 'CallExpression',
callee: importSpec.add('jsx'),
arguments: args,
leadingComments: $pureAnnotation()
});
}
},
JSXSpreadChild(path) {
path.replaceWith(path.node.expression);
},
JSXSpreadAttribute(path) {
const parent = path.parent;
if ('JSXOpeningElement' === parent.type && 'JSXIdentifier' === parent.name.type && !isFunctionComponent(parent.name)) {
throw path.buildCodeFrameError('\n\nSyntaxError: HTML, SVG, MathML or Custom Elements must not have spread attributes.\n', SyntaxError);
}
},
JSXAttribute(path) {
const attribute = path.node;
const openingElement = path.parent;
const attrValue = attribute.value;
if (jsxNode.has(attrValue?.type)) {
attribute.value = $jsxExpressionContainer(attrValue);
}
if ('JSXOpeningElement' !== openingElement.type || 'JSXIdentifier' !== openingElement.name.type) {
return;
}
const tag = openingElement.name.name;
const isHTMLElement = htmlTags.has(tag);
const isSVGElement = svgTags.has(tag);
const isStandardElement = isHTMLElement || isSVGElement || mathmlTags.has(tag);
const isCustomElement = !isStandardElement && tag.includes('-', 1);
if (!(isStandardElement || isCustomElement)) {
return;
}
const attrName = attribute.name;
if ('JSXNamespacedName' === attrName.type) {
const directive = attrName.namespace.name;
if ('on' === directive) {
eventListener(openingElement, attrName.name, attrValue);
path.remove();
} else if ('attr' === directive) {
createDirective(openingElement, {
type: 'CallExpression',
callee: {
type: 'MemberExpression',
object: $identifier('e'),
property: $identifier('setAttribute'),
computed: false
},
arguments: [$stringLiteral(attrName.name.name), convertJSXAttrValue(attrValue)]
});
path.remove();
} else if ('prop' === directive) {
const isIdent = helperValidatorIdentifier.isIdentifierName(attrName.name.name);
createDirective(openingElement, {
type: 'AssignmentExpression',
operator: '=',
left: {
type: 'MemberExpression',
object: $identifier('e'),
property: isIdent ? $identifier(attrName.name.name) : $stringLiteral(attrName.name.name),
computed: !isIdent
},
right: convertJSXAttrValue(attrValue)
});
path.remove();
} else if (isCustomElement) {
return;
} else if ('xlink' === directive && 'href' === attrName.name.name) {
attribute.name = attrName.name;
}
return;
}
if (isCustomElement) {
return;
}
if (htmlDOMAttributes.has(attrName.name)) {
attrName.name = htmlDOMAttributes.get(attrName.name);
return;
}
const aName = attrName.name.toLowerCase();
if (booleanAttributes.has(aName)) {
attrName.name = aName;
attribute.value ??= $stringLiteral('');
} else if (enumerated.has(aName) || aName.startsWith('data-')) {
attrName.name = aName;
if (null == attrValue) {
attribute.value = $stringLiteral('true');
} else if ('JSXExpressionContainer' === attrValue.type && 'BooleanLiteral' === attrValue.expression.type) {
attribute.value = $stringLiteral(attrValue.expression.value.toString());
}
} else if (DOMEvents.has(aName)) {
createDirective(openingElement, {
type: 'AssignmentExpression',
operator: '=',
left: {
type: 'MemberExpression',
object: $identifier('e'),
property: $identifier(aName),
computed: false
},
right: convertJSXAttrValue(attrValue)
});
path.remove();
} else if (isHTMLElement && attributes.has(aName)) {
attrName.name = aName;
} else if (isSVGElement && svgDOMAttributes.has(attrName.name)) {
attrName.name = svgDOMAttributes.get(attrName.name);
}
}
}
};
const preset = api => {
api.assertVersion(7);
return {
plugins: [{
manipulateOptions(_, parser) {
parser.plugins.push('jsx');
}
}, jsxTransform]
};
};
module.exports = preset;