diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index ab86bd09..ed143ef5 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -40,7 +40,7 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} - name: Archive production artifacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: dist-folder path: | diff --git a/packages/babel-plugin-jsx-dom-expressions/package.json b/packages/babel-plugin-jsx-dom-expressions/package.json index e4f73d4c..24923dc3 100644 --- a/packages/babel-plugin-jsx-dom-expressions/package.json +++ b/packages/babel-plugin-jsx-dom-expressions/package.json @@ -23,6 +23,8 @@ "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", + "jest-diff": "^29.7.0", + "jsdom": "^25.0.0", "validate-html-nesting": "^1.2.1" }, "peerDependencies": { diff --git a/packages/babel-plugin-jsx-dom-expressions/src/dom/element.js b/packages/babel-plugin-jsx-dom-expressions/src/dom/element.js index 26e82f12..d1d9a62c 100644 --- a/packages/babel-plugin-jsx-dom-expressions/src/dom/element.js +++ b/packages/babel-plugin-jsx-dom-expressions/src/dom/element.js @@ -61,15 +61,19 @@ export function transformElement(path, info) { config = getConfig(path), wrapSVG = info.topLevel && tagName != "svg" && SVGElements.has(tagName), voidTag = VoidElements.indexOf(tagName) > -1, - isCustomElement = tagName.indexOf("-") > -1 || !!path.get("openingElement").get("attributes").find(a => a.node.name?.name === "is"), + isCustomElement = tagName.indexOf("-") > -1 || path.get("openingElement").get("attributes").some(a => a.node?.name?.name === "is" || a.name?.name === "is"), + isImportNode = (tagName === 'img'||tagName === 'iframe') && path.get("openingElement").get("attributes").some(a => a.node.name?.name === "loading" && a.node.value?.value === "lazy" + ), results = { template: `<${tagName}`, + templateWithClosingTags: `<${tagName}`, declarations: [], exprs: [], dynamics: [], postExprs: [], isSVG: wrapSVG, hasCustomElement: isCustomElement, + isImportNode, tagName, renderer: "dom", skipTemplate: false @@ -95,13 +99,17 @@ export function transformElement(path, info) { return results; } } - if (wrapSVG) results.template = "" + results.template; + if (wrapSVG) { + results.template = "" + results.template; + results.templateWithClosingTags = "" + results.templateWithClosingTags; + } if (!info.skipId) results.id = path.scope.generateUidIdentifier("el$"); transformAttributes(path, results); if (config.contextToCustomElements && (tagName === "slot" || isCustomElement)) { contextToCustomElement(path, results); } results.template += ">"; + results.templateWithClosingTags += ">"; if (!voidTag) { // always close tags can still be skipped if they have no closing parents and are the last element const toBeClosed = @@ -114,6 +122,7 @@ export function transformElement(path, info) { } else results.toBeClosed = info.toBeClosed; if (tagName !== "noscript") transformChildren(path, results, config); if (toBeClosed) results.template += ``; + results.templateWithClosingTags += ``; } if (info.topLevel && config.hydratable && results.hasHydratableEvent) { let runHydrationEvents = registerImportMethod( @@ -123,7 +132,10 @@ export function transformElement(path, info) { ); results.postExprs.push(t.expressionStatement(t.callExpression(runHydrationEvents, []))); } - if (wrapSVG) results.template += ""; + if (wrapSVG) { + results.template += ""; + results.templateWithClosingTags += ""; + } return results; } @@ -216,6 +228,13 @@ export function setAttr(path, elem, name, value, { isSVG, dynamic, prevId, isCE, return t.assignmentExpression("=", t.memberExpression(elem, t.identifier("data")), value); } + if(namespace === 'bool') { + return t.callExpression( + registerImportMethod(path, "setBoolAttribute", getRendererConfig(path, "dom").moduleName), + [elem, t.stringLiteral(name), value] + ); + } + const isChildProp = ChildProperties.has(name); const isProp = Properties.has(name); const alias = getPropAlias(name, tagName.toUpperCase()); @@ -270,7 +289,7 @@ function transformAttributes(path, results) { attributes = path.get("openingElement").get("attributes"); const tagName = getTagName(path.node), isSVG = SVGElements.has(tagName), - isCE = tagName.includes("-"), + isCE = tagName.includes("-") || attributes.some(a => a.node.name?.name === 'is'), hasChildren = path.node.children.length > 0, config = getConfig(path); @@ -542,15 +561,29 @@ function transformAttributes(path, results) { children = value; } else if (key.startsWith("on")) { const ev = toEventName(key); - if (key.startsWith("on:") || key.startsWith("oncapture:")) { - const listenerOptions = [t.stringLiteral(key.split(":")[1]), value.expression]; + if (key.startsWith("on:")) { + const args = [elem, t.stringLiteral(key.split(":")[1]), value.expression]; + + results.exprs.unshift( + t.expressionStatement( + t.callExpression( + registerImportMethod( + path, + "addEventListener", + getRendererConfig(path, "dom").moduleName, + ), + args, + ), + ), + ); + } else if (key.startsWith("oncapture:")) { + // deprecated see above condition + const args = [t.stringLiteral(key.split(":")[1]), value.expression, t.booleanLiteral(true)]; results.exprs.push( t.expressionStatement( t.callExpression( t.memberExpression(elem, t.identifier("addEventListener")), - key.startsWith("oncapture:") - ? listenerOptions.concat(t.booleanLiteral(true)) - : listenerOptions + args ) ) ); @@ -697,6 +730,54 @@ function transformAttributes(path, results) { isCE, tagName }); + } else if(key.slice(0, 5) === 'bool:'){ + + // inline it on the template when possible + let content = value; + + if (t.isJSXExpressionContainer(content)) content = content.expression; + + function addBoolAttribute() { + results.template += `${needsSpacing ? " " : ""}${key.slice(5)}`; + needsSpacing = true; + } + + switch (content.type) { + case "StringLiteral": { + if (content.value.length && content.value !== "0") { + addBoolAttribute(); + } + return; + } + case "NullLiteral": { + return; + } + case "BooleanLiteral": { + if (content.value) { + addBoolAttribute(); + } + return; + } + case "Identifier": { + if (content.name === "undefined") { + return; + } + break; + } + } + + // when not possible to inline it in the template + results.exprs.push( + t.expressionStatement( + setAttr( + attribute, + elem, + key, + t.isJSXExpressionContainer(value) ? value.expression : value, + { isSVG, isCE, tagName }, + ), + ), + ); } else { results.exprs.push( t.expressionStatement( @@ -718,6 +799,7 @@ function transformAttributes(path, results) { } else { !isSVG && (key = key.toLowerCase()); results.template += `${needsSpacing ? ' ' : ''}${key}`; + results.templateWithClosingTags += `${needsSpacing ? ' ' : ''}${key}`; if (!value) { needsSpacing = true; return; @@ -737,6 +819,7 @@ function transformAttributes(path, results) { if (!text.length) { needsSpacing = false; results.template += `=""`; + results.templateWithClosingTags += `=""`; return; } @@ -762,9 +845,11 @@ function transformAttributes(path, results) { if (needsQuoting) { needsSpacing = false; results.template += `="${escapeHTML(text, true)}"`; + results.templateWithClosingTags += `="${escapeHTML(text, true)}"`; } else { needsSpacing = true; results.template += `=${escapeHTML(text, true)}`; + results.templateWithClosingTags += `=${escapeHTML(text, true)}`; } } } @@ -818,6 +903,7 @@ function transformChildren(path, results, config) { const i = memo.length; if (transformed.text && i && memo[i - 1].text) { memo[i - 1].template += transformed.template; + memo[i - 1].templateWithClosingTags += transformed.templateWithClosingTags || transformed.template; } else memo.push(transformed); return memo; }, []); @@ -830,6 +916,9 @@ function transformChildren(path, results, config) { } results.template += child.template; + results.templateWithClosingTags += child.templateWithClosingTags || child.template ; + results.isImportNode = results.isImportNode || child.isImportNode; + if (child.id) { if (child.tagName === "head") { if (config.hydratable) { @@ -878,6 +967,7 @@ function transformChildren(path, results, config) { childPostExprs.push(...child.postExprs); results.hasHydratableEvent = results.hasHydratableEvent || child.hasHydratableEvent; results.hasCustomElement = results.hasCustomElement || child.hasCustomElement; + results.isImportNode = results.isImportNode || child.isImportNode; tempPath = child.id.name; nextPlaceholder = null; i++; @@ -931,6 +1021,7 @@ function createPlaceholder(path, results, tempPath, i, char) { config = getConfig(path); let contentId; results.template += ``; + results.templateWithClosingTags += ``; if (config.hydratable && char === "/") { contentId = path.scope.generateUidIdentifier("co$"); results.declarations.push( @@ -980,7 +1071,7 @@ function detectExpressions(children, index, config) { } else if (t.isJSXElement(child)) { const tagName = getTagName(child); if (isComponent(tagName)) return true; - if (config.contextToCustomElements && (tagName === "slot" || tagName.indexOf("-") > -1)) + if (config.contextToCustomElements && (tagName === "slot" || tagName.indexOf("-") > -1 || child.openingElement.attributes.some(a => a.name?.name === 'is'))) return true; if ( child.openingElement.attributes.some( diff --git a/packages/babel-plugin-jsx-dom-expressions/src/dom/template.js b/packages/babel-plugin-jsx-dom-expressions/src/dom/template.js index 675cc21b..a539ef06 100644 --- a/packages/babel-plugin-jsx-dom-expressions/src/dom/template.js +++ b/packages/babel-plugin-jsx-dom-expressions/src/dom/template.js @@ -40,14 +40,17 @@ export function appendTemplates(path, templates) { cooked: template.template, raw: escapeStringForTemplate(template.template) }; + + const shouldUseImportNode = template.isCE || template.isImportNode + return t.variableDeclarator( template.id, t.addComment( t.callExpression( registerImportMethod(path, "template", getRendererConfig(path, "dom").moduleName), [t.templateLiteral([t.templateElement(tmpl, true)], [])].concat( - template.isSVG || template.isCE - ? [t.booleanLiteral(template.isCE), t.booleanLiteral(template.isSVG)] + template.isSVG || shouldUseImportNode + ? [t.booleanLiteral(!!shouldUseImportNode), t.booleanLiteral(template.isSVG)] : [] ) ), @@ -75,8 +78,10 @@ function registerTemplate(path, results) { templates.push({ id: templateId, template: results.template, + templateWithClosingTags: results.templateWithClosingTags, isSVG: results.isSVG, isCE: results.hasCustomElement, + isImportNode: results.isImportNode, renderer: "dom" }); } diff --git a/packages/babel-plugin-jsx-dom-expressions/src/shared/postprocess.js b/packages/babel-plugin-jsx-dom-expressions/src/shared/postprocess.js index 73722406..47f88c2a 100644 --- a/packages/babel-plugin-jsx-dom-expressions/src/shared/postprocess.js +++ b/packages/babel-plugin-jsx-dom-expressions/src/shared/postprocess.js @@ -2,6 +2,8 @@ import * as t from "@babel/types"; import { getRendererConfig, registerImportMethod } from "./utils"; import { appendTemplates as appendTemplatesDOM } from "../dom/template"; import { appendTemplates as appendTemplatesSSR } from "../ssr/template"; +import { isInvalidMarkup } from "./validate.js"; +const { diff } = require("jest-diff"); // add to the top/bottom of the module. export default path => { @@ -16,6 +18,21 @@ export default path => { ); } if (path.scope.data.templates?.length) { + for (const template of path.scope.data.templates) { + const html = template.templateWithClosingTags; + // not sure when/why this is not a string + if (typeof html === "string") { + const result = isInvalidMarkup(html); + if (result) { + const message = + "The HTML provided is malformed and will yield unexpected output when evaluated by a browser.\n"; + console.warn(message); + console.log(diff(result.html, result.browser)); + console.warn("Original HTML:\n", html); + // throw path.buildCodeFrameError(); + } + } + } let domTemplates = path.scope.data.templates.filter(temp => temp.renderer === "dom"); let ssrTemplates = path.scope.data.templates.filter(temp => temp.renderer === "ssr"); domTemplates.length > 0 && appendTemplatesDOM(path, domTemplates); diff --git a/packages/babel-plugin-jsx-dom-expressions/src/shared/preprocess.js b/packages/babel-plugin-jsx-dom-expressions/src/shared/preprocess.js index 556684d6..d90f7435 100644 --- a/packages/babel-plugin-jsx-dom-expressions/src/shared/preprocess.js +++ b/packages/babel-plugin-jsx-dom-expressions/src/shared/preprocess.js @@ -8,6 +8,7 @@ const JSXValidator = { JSXElement(path) { const elName = path.node.openingElement.name; const parent = path.parent; + if (!t.isJSXElement(parent) || !t.isJSXIdentifier(elName)) return; const elTagName = elName.name; if (isComponent(elTagName)) return; diff --git a/packages/babel-plugin-jsx-dom-expressions/src/shared/utils.js b/packages/babel-plugin-jsx-dom-expressions/src/shared/utils.js index dacd7bc0..e9a5d1ca 100644 --- a/packages/babel-plugin-jsx-dom-expressions/src/shared/utils.js +++ b/packages/babel-plugin-jsx-dom-expressions/src/shared/utils.js @@ -8,10 +8,11 @@ export const reservedNameSpaces = new Set([ "style", "use", "prop", - "attr" + "attr", + "bool" ]); -export const nonSpreadNameSpaces = new Set(["class", "style", "use", "prop", "attr"]); +export const nonSpreadNameSpaces = new Set(["class", "style", "use", "prop", "attr", "bool"]); export function getConfig(path) { return path.hub.file.metadata.config; diff --git a/packages/babel-plugin-jsx-dom-expressions/src/shared/validate.js b/packages/babel-plugin-jsx-dom-expressions/src/shared/validate.js new file mode 100644 index 00000000..c5db3a53 --- /dev/null +++ b/packages/babel-plugin-jsx-dom-expressions/src/shared/validate.js @@ -0,0 +1,62 @@ +// fix me: jsdom crashes without this +const util = require("util"); +const { TextEncoder, TextDecoder } = util; +Object.assign(global, { TextDecoder, TextEncoder }); + +const JSDOM = require("jsdom").JSDOM; +const Element = new JSDOM(``).window.document.body; + +/** + * Returns an object with information when the markup is invalid + * + * @param {string} html - The html string to validate + * @returns {{ + * html: string; // html stripped of attributives and content + * browser: string; // what the browser returned from evaluating `html` + * } | null} + */ +export function isInvalidMarkup(html) { + html = html + + // normalize dom-expressions comments, so comments location are also validated + .replaceAll("", "") + .replaceAll("", "") + .replaceAll("", "") + + // replace text nodes + // text nodes are problematic, think "doesn't" vs "doesn't" + // we can detect if text nodes were moved by the browser when the `#text` moves + + // replace text nodes that isnt in between tags by `#text` + .replace(/^[^<]+/, "#text") + .replace(/[^>]+$/, "#text") + // replace text nodes in between tags by `#text` + .replace(/>[^<]+#text<") + + // remove attributes (the lack of quotes will make it mismatch) + .replace(/<([a-z0-9-]+)\s+[^>]+>/gi, "<$1>") + + // fix escaping, so doesnt mess up the validation + // `<script>a();</script>` -> `<script>a();</script>` + .replace(/<([^>]+)>/gi, "<$1>") + + // edge cases (safe to assume they will use the partial in the right place) + // fix tables rows + .replace(/^/i, "") + .replace(/<\/tr>$/i, "
") + // fix tables cells + .replace(/^/i, "
") + .replace(/<\/td>$/i, "
"); + + // parse + Element.innerHTML = html; + // result + const browser = Element.innerHTML; + + if (html !== browser) { + return { + html, + browser + }; + } +} diff --git a/packages/babel-plugin-jsx-dom-expressions/src/ssr/element.js b/packages/babel-plugin-jsx-dom-expressions/src/ssr/element.js index a76853ba..64bd7a60 100644 --- a/packages/babel-plugin-jsx-dom-expressions/src/ssr/element.js +++ b/packages/babel-plugin-jsx-dom-expressions/src/ssr/element.js @@ -58,6 +58,7 @@ export function transformElement(path, info) { registerImportMethod(path, "createComponent"); const child = transformElement(path, { ...info, topLevel: false }); results.template = ""; + results.templateWithClosingTags = ""; results.exprs.push( t.callExpression(t.identifier("_$createComponent"), [ t.identifier("_$NoHydration"), diff --git a/packages/babel-plugin-jsx-dom-expressions/src/ssr/template.js b/packages/babel-plugin-jsx-dom-expressions/src/ssr/template.js index 62e90d24..8dcf0665 100644 --- a/packages/babel-plugin-jsx-dom-expressions/src/ssr/template.js +++ b/packages/babel-plugin-jsx-dom-expressions/src/ssr/template.js @@ -33,6 +33,7 @@ export function createTemplate(path, result) { templates.push({ id, template, + templateWithClosingTags:template, renderer: "ssr" }); } else id = found.id; diff --git a/packages/babel-plugin-jsx-dom-expressions/src/universal/element.js b/packages/babel-plugin-jsx-dom-expressions/src/universal/element.js index e6645e21..9498e3d6 100644 --- a/packages/babel-plugin-jsx-dom-expressions/src/universal/element.js +++ b/packages/babel-plugin-jsx-dom-expressions/src/universal/element.js @@ -217,6 +217,7 @@ function transformChildren(path, results) { const i = memo.length; if (child.text && i && memo[i - 1].text) { memo[i - 1].template += child.template; + memo[i - 1].templateWithClosingTags += child.templateWithClosingTags || child.template; } else memo.push(child); return memo; }, []); diff --git a/packages/babel-plugin-jsx-dom-expressions/test/__dom_fixtures__/attributeExpressions/code.js b/packages/babel-plugin-jsx-dom-expressions/test/__dom_fixtures__/attributeExpressions/code.js index 0c74d090..2b10fb0c 100644 --- a/packages/babel-plugin-jsx-dom-expressions/test/__dom_fixtures__/attributeExpressions/code.js +++ b/packages/babel-plugin-jsx-dom-expressions/test/__dom_fixtures__/attributeExpressions/code.js @@ -202,3 +202,43 @@ const template41 = ( ); + +// bool: +function boolTest(){return true} +const boolTestBinding = false +const boolTestObjBinding = {value:false} + +const template42 =
empty string
; +const template43 =
js empty
; +const template44 =
hola
; +const template45 =
"hola js"
; +const template46 =
true
; +const template47 =
false
; +const template48 =
1
; +const template49 =
0
; +const template50 =
"1"
; +const template51 =
"0"
; +const template52 =
undefined
; +const template53 =
null
; +const template54 =
boolTest()
; +const template55 =
boolTest
; +const template56 =
boolTestBinding
; +const template57 =
boolTestObjBinding.value
; +const template58 =
false}>fn
; + +const template59 =
should have space before
; +const template60 =
should have space before/after
; +const template61 =
should have space before/after
; +// this crash it for some reason- */ const template62 =
really empty
; + +const template63 = ; +const template64 =
; + +const template65 = ; +const template66 =
; + +const template67 = ; +const template68 =
; + +const template69 = ; +const template70 =
; diff --git a/packages/babel-plugin-jsx-dom-expressions/test/__dom_fixtures__/attributeExpressions/output.js b/packages/babel-plugin-jsx-dom-expressions/test/__dom_fixtures__/attributeExpressions/output.js index f89ba09a..dacec789 100644 --- a/packages/babel-plugin-jsx-dom-expressions/test/__dom_fixtures__/attributeExpressions/output.js +++ b/packages/babel-plugin-jsx-dom-expressions/test/__dom_fixtures__/attributeExpressions/output.js @@ -1,5 +1,6 @@ import { template as _$template } from "r-dom"; import { delegateEvents as _$delegateEvents } from "r-dom"; +import { setBoolAttribute as _$setBoolAttribute } from "r-dom"; import { insert as _$insert } from "r-dom"; import { memo as _$memo } from "r-dom"; import { addEventListener as _$addEventListener } from "r-dom"; @@ -32,7 +33,35 @@ var _tmpl$ = /*#__PURE__*/ _$template(`

`), _tmpl$19 = /*#__PURE__*/ _$template(``), - _tmpl$20 = /*#__PURE__*/ _$template(`