diff --git a/.changeset/friendly-doors-sit.md b/.changeset/friendly-doors-sit.md new file mode 100644 index 000000000..591b80816 --- /dev/null +++ b/.changeset/friendly-doors-sit.md @@ -0,0 +1,7 @@ +--- +"@marko/translator-default": patch +"marko": patch +"@marko/compiler": patch +--- + +Improve nested attribute tag handling with scriptlets. diff --git a/packages/marko/src/runtime/helpers/attr-tag.js b/packages/marko/src/runtime/helpers/attr-tag.js new file mode 100644 index 000000000..089879b78 --- /dev/null +++ b/packages/marko/src/runtime/helpers/attr-tag.js @@ -0,0 +1,40 @@ +"use strict"; + +var ownerInput; + +exports.r = function repeatedAttrTag(targetProperty, attrTagInput) { + var prev = ownerInput[targetProperty]; + if (prev) { + prev.push(attrTagInput); + } else { + ownerInput[targetProperty] = [attrTagInput]; + } +}; +exports.a = function repeatableAttrTag(targetProperty, attrTagInput) { + var prev = ownerInput[targetProperty]; + if (prev) { + if (Array.isArray(prev)) { + prev.push(attrTagInput); + } else { + ownerInput[targetProperty] = [prev, attrTagInput]; + } + } else { + attrTagInput[Symbol.iterator] = selfIterator; + ownerInput[targetProperty] = attrTagInput; + } +}; + +exports.i = function attrTagInput(render, input) { + var prevOwnerInput = ownerInput; + ownerInput = input || {}; + try { + ownerInput.renderBody = render(); + return ownerInput; + } finally { + ownerInput = prevOwnerInput; + } +}; + +function* selfIterator() { + yield this; +} diff --git a/packages/marko/src/runtime/helpers/load-nested-tag.js b/packages/marko/src/runtime/helpers/load-nested-tag.js deleted file mode 100644 index bf0cabe4d..000000000 --- a/packages/marko/src/runtime/helpers/load-nested-tag.js +++ /dev/null @@ -1,17 +0,0 @@ -"use strict"; - -module.exports = function loadNestedTagHelper(targetProperty, isRepeated) { - return function (input, parent) { - // If we are nested tag then we do not have a renderer - if (isRepeated) { - var existingArray = parent[targetProperty]; - if (existingArray) { - existingArray.push(input); - } else { - parent[targetProperty] = [input]; - } - } else { - parent[targetProperty] = input; - } - }; -}; diff --git a/packages/marko/src/runtime/helpers/self-iterator.js b/packages/marko/src/runtime/helpers/self-iterator.js deleted file mode 100644 index 217ff1b79..000000000 --- a/packages/marko/src/runtime/helpers/self-iterator.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = function* selfIterator() { - yield this; -}; diff --git a/packages/translator-default/src/index.js b/packages/translator-default/src/index.js index 19d5ad321..aa79be769 100644 --- a/packages/translator-default/src/index.js +++ b/packages/translator-default/src/index.js @@ -472,10 +472,9 @@ export function getRuntimeEntryFiles(output, optimize) { `${base}runtime/helpers/assign.js`, `${base}runtime/helpers/class-value.js`, `${base}runtime/helpers/dynamic-tag.js`, - `${base}runtime/helpers/load-nested-tag.js`, + `${base}runtime/helpers/attr-tag.js`, `${base}runtime/helpers/merge.js`, `${base}runtime/helpers/repeatable.js`, - `${base}runtime/helpers/self-iterator.js`, `${base}runtime/helpers/render-tag.js`, `${base}runtime/helpers/style-value.js`, `${base}runtime/helpers/to-string.js`, diff --git a/packages/translator-default/src/tag/attribute-tag.js b/packages/translator-default/src/tag/attribute-tag.js index 3ff3280d9..74a488863 100644 --- a/packages/translator-default/src/tag/attribute-tag.js +++ b/packages/translator-default/src/tag/attribute-tag.js @@ -1,188 +1,186 @@ import { assertNoArgs, findParentTag, + getFullyResolvedTagName, getTagDef, - importDefault, + importNamed, isAttributeTag, isTransparentTag, } from "@marko/babel-utils"; import { types as t } from "@marko/compiler"; -import withPreviousLocation from "../util/with-previous-location"; import { getAttrs } from "./util"; -const EMPTY_OBJECT = {}; -const parentIdentifierLookup = new WeakMap(); +const contentTypeCache = new WeakMap(); +const ContentType = { + attribute: 0, + render: 1, + mixed: 2, +}; -// TODO: optimize inline repeated @tags. +export function analyzeAttributeTags(rootTag) { + const visit = [rootTag]; + const parentTags = [rootTag]; + let i = 0; + let attributeTags; -export default function (tag) { - const { node } = tag; - const namePath = tag.get("name"); - const tagName = namePath.node.value; - const parentPath = findParentTag(tag); + while (i < visit.length) { + const tag = visit[i++]; + for (const child of tag.get("body").get("body")) { + if (isAttributeTag(child)) { + assertNoArgs(child); + const tagDef = getTagDef(child) || {}; + const name = getFullyResolvedTagName(child); + let { + targetProperty = child.node.name.value.slice(1), + isRepeated = false, + } = tagDef; - assertNoArgs(tag); + const preserveName = + tagDef.preserveName === true || tagDef.removeDashes === false; - if (!parentPath) { - throw namePath.buildCodeFrameError( - "@tags must be nested within another element.", - ); - } + if (!preserveName) { + targetProperty = removeDashes(targetProperty); + } - const parentAttributes = parentPath.get("attributes"); - const tagDef = getTagDef(tag); - const { isRepeated, targetProperty = tagName.slice(1) } = - tagDef || EMPTY_OBJECT; - const isDynamic = isRepeated || parentPath !== tag.parentPath.parentPath; - parentPath.node.exampleAttributeTag = node; + const attrTagMeta = ((attributeTags ||= {})[name] ||= { + targetProperty, + isRepeated, + }); - if (isDynamic) { - if (!parentPath.node.hasDynamicAttrTags) { - const body = parentPath.get("body").get("body"); - parentPath.node.hasDynamicAttrTags = true; + (child.node.extra ||= {}).attributeTag = attrTagMeta; - for (let i = body.length; i--; ) { - const child = body[i]; - if (isAttributeTagChild(child)) { - child.insertAfter(t.stringLiteral("END_ATTRIBUTE_TAGS")); - break; + const parentTag = findParentTag(child); + const parentTagExtra = (parentTag.node.extra ||= {}); + parentTagExtra.hasAttributeTags = true; + parentTags.push(child); + visit.push(child); + } else if (isTransparentTag(child)) { + switch (getContentType(child)) { + case ContentType.mixed: + throw child.buildCodeFrameError( + "Cannot mix @tags with other content when under a control flow.", + ); + case ContentType.attribute: + visit.push(child); + break; + case ContentType.render: + break; } } } - } else { - const previousAttr = parentAttributes.find( - (attr) => attr.get("name").node === targetProperty, - ); + } - if (previousAttr) { - const previousValue = previousAttr.get("value").node; - if (t.isObjectExpression(previousValue)) { - previousAttr.set( - "value", - t.arrayExpression([previousValue, getAttrTagObject(tag)]), - ); - } else if (t.isArrayExpression(previousAttr)) { - previousAttr.elements.push(getAttrTagObject(tag)); - } else { - previousAttr.set( - "value", - t.callExpression( - importDefault( - tag.hub.file, - "marko/src/runtime/helpers/repeatable.js", - "marko_repeatable", - ), - [previousValue, getAttrTagObject(tag)], - ), - ); + if (attributeTags) { + (rootTag.node.extra ??= {}).attributeTags = attributeTags; + + for (const parentTag of parentTags) { + if (getContentType(parentTag) === ContentType.mixed) { + // move all non scriptlet / attribute tag children to the end of the renderbody + const renderBody = [ + t.expressionStatement(t.stringLiteral("END_ATTRIBUTE_TAGS")), + ]; + const body = parentTag.get("body"); + for (const child of body.get("body")) { + if ( + child.isMarkoScriptlet() || + isAttributeTag(child) || + (isTransparentTag(child) && + getContentType(child) === ContentType.attribute) + ) { + continue; + } + + renderBody.push(child.node); + child.remove(); + } + + body.node.body = body.node.body.concat(renderBody); } - } else { - parentPath.pushContainer( - "attributes", - t.markoAttribute(targetProperty, getAttrTagObject(tag)), - ); } + } +} - tag.remove(); - return; +export default function translateAttributeTag(tag) { + const { node } = tag; + const meta = node.extra?.attributeTag; + if (!meta) { + throw tag + .get("name") + .buildCodeFrameError("@tags must be nested within another element."); } - let identifiers = parentIdentifierLookup.get(parentPath); + assertNoArgs(tag); - if (!identifiers) { - parentIdentifierLookup.set(parentPath, (identifiers = {})); - } - - let identifier = identifiers[targetProperty]; - - if (!identifier) { - identifier = identifiers[targetProperty] = - tag.scope.generateUidIdentifier(targetProperty); - parentPath - .get("body") - .unshiftContainer( - "body", - t.variableDeclaration(isRepeated ? "const" : "let", [ - t.variableDeclarator( - identifier, - isRepeated ? t.arrayExpression([]) : t.nullLiteral(), - ), - ]), - ); - parentPath.pushContainer( - "attributes", - t.markoAttribute(targetProperty, identifier), - ); - } - - if (isRepeated) { - tag.replaceWith( - withPreviousLocation( - t.expressionStatement( - t.callExpression( - t.memberExpression(identifier, t.identifier("push")), - [getAttrTagObject(tag)], - ), + tag.replaceWith( + t.expressionStatement( + t.callExpression( + importNamed( + tag.hub.file, + "marko/src/runtime/helpers/attr-tag.js", + meta.isRepeated ? "r" : "a", + meta.isRepeated + ? "marko_repeated_attr_tag" + : "marko_repeatable_attr_tag", ), - node, + [t.stringLiteral(meta.targetProperty), getAttrTagObject(tag)], ), - ); - } else { - tag.replaceWith( - withPreviousLocation( - t.expressionStatement( - t.assignmentExpression( - "=", - identifier, - t.callExpression( - importDefault( - tag.hub.file, - "marko/src/runtime/helpers/repeatable.js", - "marko_repeatable", - ), - [identifier, getAttrTagObject(tag)], - ), - ), - ), - node, - ), - ); - } + ), + ); } function getAttrTagObject(tag) { const attrs = getAttrs(tag); - const iteratorProp = t.objectProperty( - t.memberExpression(t.identifier("Symbol"), t.identifier("iterator")), - importDefault( - tag.hub.file, - "marko/src/runtime/helpers/self-iterator.js", - "marko_self_iterator", - ), - true, - ); if (t.isNullLiteral(attrs)) { - return t.objectExpression([iteratorProp]); + return t.objectExpression([]); } - if (t.isObjectExpression(attrs)) { - attrs.properties.push(iteratorProp); - return attrs; - } - - return t.objectExpression([iteratorProp, t.spreadElement(attrs)]); + return attrs; } -function isAttributeTagChild(tag) { - if (isAttributeTag(tag)) { - return true; +function getContentType(tag) { + const { node } = tag; + const cached = contentTypeCache.get(node); + if (cached !== undefined) return cached; + + const body = tag.get("body").get("body"); + let hasAttributeTag = false; + let hasRenderBody = false; + + for (const child of body) { + if (isAttributeTag(child)) { + hasAttributeTag = true; + } else if (isTransparentTag(child)) { + switch (getContentType(child)) { + case ContentType.mixed: + contentTypeCache.set(node, ContentType.mixed); + return ContentType.mixed; + case ContentType.attribute: + hasAttributeTag = true; + break; + case ContentType.render: + hasRenderBody = true; + break; + } + } else if (!child.isMarkoScriptlet()) { + hasRenderBody = true; + } + + if (hasAttributeTag && hasRenderBody) { + contentTypeCache.set(node, ContentType.mixed); + return ContentType.mixed; + } } - if (isTransparentTag(tag)) { - const body = tag.get("body").get("body"); - return isAttributeTagChild(body[body.length - 1]); - } - - return false; + const result = hasAttributeTag ? ContentType.attribute : ContentType.render; + contentTypeCache.set(node, result); + return result; +} + +function removeDashes(str) { + return str.replace(/-([a-z])/g, matchToUpperCase); +} + +function matchToUpperCase(_match, lower) { + return lower.toUpperCase(); } diff --git a/packages/translator-default/src/tag/index.js b/packages/translator-default/src/tag/index.js index 83b6afa7a..416b0e55f 100644 --- a/packages/translator-default/src/tag/index.js +++ b/packages/translator-default/src/tag/index.js @@ -13,7 +13,7 @@ import { getKeyManager } from "../util/key-manager"; import { optimizeStaticVDOM } from "../util/optimize-vdom-create"; import { enter, exit } from "../util/plugin-hooks"; import attributeTranslators from "./attribute"; -import attributeTag from "./attribute-tag"; +import attributeTag, { analyzeAttributeTags } from "./attribute-tag"; import customTag from "./custom-tag"; import dynamicTag from "./dynamic-tag"; import macroTag from "./macro-tag"; @@ -54,6 +54,10 @@ export default { } if (!isAttributeTag(path)) { + if (isDynamicTag(path) || !(isMacroTag(path) || isNativeTag(path))) { + analyzeAttributeTags(path); + } + getKeyManager(path).resolveKey(path); } diff --git a/packages/translator-default/src/tag/util.js b/packages/translator-default/src/tag/util.js index 5f87f5755..4173ff97b 100644 --- a/packages/translator-default/src/tag/util.js +++ b/packages/translator-default/src/tag/util.js @@ -1,14 +1,14 @@ -import { computeNode, getTagDef } from "@marko/babel-utils"; +import { computeNode, getTagDef, importNamed } from "@marko/babel-utils"; import { types as t } from "@marko/compiler"; import classToString from "marko/src/runtime/helpers/class-value"; import styleToString from "marko/src/runtime/helpers/style-value"; -export function getAttrs(path, preserveNames, skipRenderBody) { +export function getAttrs(path, preserveNames) { const { node } = path; const { + extra, attributes, body: { body, params }, - hasDynamicAttrTags, } = node; const attrsLen = attributes.length; const childLen = body.length; @@ -16,6 +16,7 @@ export function getAttrs(path, preserveNames, skipRenderBody) { const targetObjects = {}; const tagDef = getTagDef(path); const foundProperties = {}; + const hasAttributeTags = extra?.hasAttributeTags; for (let i = 0; i < attrsLen; i++) { const { name, value } = attributes[i]; @@ -69,34 +70,16 @@ export function getAttrs(path, preserveNames, skipRenderBody) { } } - if (!skipRenderBody && childLen) { - let endDynamicAttrTagsIndex = -1; - - if (hasDynamicAttrTags) { - endDynamicAttrTagsIndex = findLastIndex( - body, - ({ value }) => value === "END_ATTRIBUTE_TAGS", - ); - path - .insertBefore(body.slice(0, endDynamicAttrTagsIndex)) - .map((child) => child.skip()); - } - - if (!hasDynamicAttrTags || endDynamicAttrTagsIndex !== childLen - 1) { - properties.push( - t.objectProperty( - t.stringLiteral("renderBody"), - t.arrowFunctionExpression( - [t.identifier("out"), ...params], - t.blockStatement( - hasDynamicAttrTags - ? body.slice(endDynamicAttrTagsIndex + 1) - : body, - ), - ), + if (childLen && !hasAttributeTags) { + properties.push( + t.objectProperty( + t.stringLiteral("renderBody"), + t.arrowFunctionExpression( + [t.identifier("out"), ...params], + t.blockStatement(body), ), - ); - } + ), + ); } // Default parameters @@ -121,15 +104,55 @@ export function getAttrs(path, preserveNames, skipRenderBody) { } }); - if (properties.length === 0) { - return t.nullLiteral(); + let attrsObject = + properties.length === 0 + ? t.nullLiteral() + : !hasAttributeTags && + properties.length === 1 && + t.isSpreadElement(properties[0]) + ? properties[0].argument + : t.objectExpression(properties); + + if (hasAttributeTags) { + const endAttributeTagsIndex = findLastIndex( + body, + (node) => + t.isExpressionStatement(node) && + t.isStringLiteral(node.expression) && + node.expression.value === "END_ATTRIBUTE_TAGS", + ); + + let attrTagBody = body; + + if (endAttributeTagsIndex !== -1) { + attrTagBody = body.slice(0, endAttributeTagsIndex); + attrTagBody.push( + t.returnStatement( + t.arrowFunctionExpression( + [t.identifier("out"), ...params], + t.blockStatement(body.slice(endAttributeTagsIndex + 1)), + ), + ), + ); + } + + const attrTagFn = t.arrowFunctionExpression( + [], + t.blockStatement(attrTagBody), + ); + + attrsObject = t.callExpression( + importNamed( + path.hub.file, + "marko/src/runtime/helpers/attr-tag.js", + "i", + "marko_render_input", + ), + properties.length === 0 ? [attrTagFn] : [attrTagFn, attrsObject], + ); } - if (properties.length === 1 && t.isSpreadElement(properties[0])) { - return properties[0].argument; - } - - return t.objectExpression(properties); + return attrsObject; } export function buildEventHandlerArray(path) { @@ -207,6 +230,8 @@ function findLastIndex(arr, check) { return i; } } + + return -1; } function mergeSpread(properties, value) {