UNPKG

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
'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;