import * as t from '@babel/types'; import { ELEMENT_TYPE_MAP } from '../constants/selectors.js'; export const EDITABLE_HTML_TAGS = ["a", "Link", "button", "Button", "p", "span", "time", "h1", "h2", "h3", "h4", "h5", "h6", "label", "Label", "img"]; export const INLINE_EDIT_HTML_TAGS = ["br", "strong", "em", "b", "i", "u", "span", "a"]; /** * @param {import('@babel/types').JSXOpeningElement} openingElementNode * @param {string[]} editableTagsList * @returns {boolean} */ export function checkTagNameEditable(openingElementNode, editableTagsList) { if (!openingElementNode || !openingElementNode.name) return false; const nameNode = openingElementNode.name; if (nameNode.type === 'JSXIdentifier' && editableTagsList.includes(nameNode.name)) { return true; } if (nameNode.type === 'JSXMemberExpression' && nameNode.property && nameNode.property.type === 'JSXIdentifier' && editableTagsList.includes(nameNode.property.name)) { return true; } return false; } /** * @param {import('@babel/types').JSXOpeningElement} openingNode * @returns {{ isValid: boolean, reason: string|null }} */ export function validateImageSrc(openingNode) { if (!openingNode || !openingNode.name || (openingNode.name.name !== 'img' && openingNode.name.property?.name !== 'img')) { return { isValid: true, reason: null }; } const hasPropsSpread = openingNode.attributes.some(attribute => t.isJSXSpreadAttribute(attribute) && attribute.argument && t.isIdentifier(attribute.argument) && attribute.argument.name === 'props' ); if (hasPropsSpread) return { isValid: false, reason: 'props-spread' }; const sourceAttribute = openingNode.attributes.find(attribute => t.isJSXAttribute(attribute) && attribute.name && attribute.name.name === 'src' ); if (!sourceAttribute) return { isValid: false, reason: 'missing-src' }; if (!t.isStringLiteral(sourceAttribute.value)) return { isValid: false, reason: 'dynamic-src' }; if (!sourceAttribute.value.value || sourceAttribute.value.value.trim() === '') return { isValid: false, reason: 'empty-src' }; return { isValid: true, reason: null }; } // `children` and capitalised identifiers hold rendered nodes, not text, so an // element whose only content is one of those is a layout slot rather than a target. function isNodeSlotExpression(expression) { if (t.isIdentifier(expression)) return expression.name === 'children' || /^[A-Z]/.test(expression.name); return t.isMemberExpression(expression) && expression.property?.name === 'children'; } function isLikelyTextExpression(expression) { if (isNodeSlotExpression(expression)) return false; // A ternary or `&&` still renders text through whichever branch wins. Without // this, `{ready ? `${n} entries` : 'Loading'}` reads as an empty element and // the span is skipped entirely instead of being marked assisted. if (t.isConditionalExpression(expression)) { return isLikelyTextExpression(expression.consequent) || isLikelyTextExpression(expression.alternate); } if (t.isLogicalExpression(expression)) { return isLikelyTextExpression(expression.right); } return t.isIdentifier(expression) || t.isMemberExpression(expression) || t.isStringLiteral(expression) || t.isNumericLiteral(expression) || t.isTemplateLiteral(expression); } /** * True when an element has direct text children worth editing * @param {import('@babel/types').JSXElement} elementNode * @returns {boolean} */ export function hasMeaningfulTextContent(elementNode) { if (!t.isJSXElement(elementNode) || !elementNode.children?.length) return false; for (const child of elementNode.children) { if (t.isJSXText(child) && child.value.trim().length > 0) return true; if (t.isJSXExpressionContainer(child) && !t.isJSXEmptyExpression(child.expression) && isLikelyTextExpression(child.expression)) return true; } return false; } /** * Like {@link hasMeaningfulTextContent} but walks the full subtree. * Used to distinguish decorative inline children (color swatches, icons) * from ones that carry actual text. */ function subtreeHasText(elementNode) { if (!t.isJSXElement(elementNode)) return false; for (const child of elementNode.children ?? []) { if (t.isJSXText(child) && child.value.trim().length > 0) return true; if (t.isJSXExpressionContainer(child) && !t.isJSXEmptyExpression(child.expression) && isLikelyTextExpression(child.expression)) return true; if (t.isJSXElement(child) && subtreeHasText(child)) return true; } return false; } /** * Returns true if the element should receive an assisted-edit id due to spread * props or dynamic children that contain text. Icon-only buttons are excluded. * @param {import('@babel/types').JSXElement} elementNode * @param {import('@babel/types').JSXOpeningElement} openingNode * @returns {boolean} */ export function shouldDisableDueToChildren(elementNode, openingNode) { if (!t.isJSXElement(elementNode) || !elementNode.children) return false; const hasPropsSpread = openingNode.attributes.some(attribute => t.isJSXSpreadAttribute(attribute) && attribute.argument && t.isIdentifier(attribute.argument) && attribute.argument.name === 'props' ); if (hasPropsSpread) return true; const hasDynamicChild = elementNode.children.some(child => t.isJSXExpressionContainer(child)); if (hasDynamicChild) { const hasStaticText = elementNode.children.some( child => t.isJSXText(child) && child.value.trim().length > 0 ); if (hasStaticText) return true; const hasTextLikeExpression = elementNode.children.some(child => t.isJSXExpressionContainer(child) && !t.isJSXEmptyExpression(child.expression) && isLikelyTextExpression(child.expression) ); if (hasTextLikeExpression) return true; } return false; } /** * Analyses child composition to decide whether the element can be edited. * @param {import('@babel/types').JSXElement} elementNode * @param {import('@babel/types').JSXOpeningElement} openingNode * @returns {'skip'|'disabled'|'allow'} */ export function evalChildComposition(elementNode, openingNode) { if (!t.isJSXElement(elementNode)) return 'allow'; if (!elementNode.children?.length) { const name = openingNode?.name?.name || openingNode?.name?.property?.name || ''; return name === 'img' ? 'allow' : 'skip'; } let hasTextContent = false; let hasNonEditableJsxChild = false; let hasNonSelfClosingChild = false; let hasInlineTagChild = false; let hasIdIneligibleInlineChild = false; for (const child of elementNode.children) { if (t.isJSXText(child)) { if (child.value.trim().length > 0) hasTextContent = true; continue; } if (t.isJSXExpressionContainer(child)) { if (!t.isJSXEmptyExpression(child.expression) && isLikelyTextExpression(child.expression)) { hasTextContent = true; } continue; } if (t.isJSXElement(child)) { const childNode = child.openingElement; if (childNode.selfClosing) { const childName = childNode.name?.name || ''; if (!/^[A-Z]/.test(childName) && !checkTagNameEditable(childNode, EDITABLE_HTML_TAGS) && !INLINE_EDIT_HTML_TAGS.includes(childName)) { hasNonEditableJsxChild = true; } continue; } const childTagName = child.openingElement.name?.name; if (INLINE_EDIT_HTML_TAGS.includes(childTagName)) { if (!subtreeHasText(child)) { hasNonEditableJsxChild = true; continue; } hasInlineTagChild = true; // `strong`/`em`/… are inline-editable inside a parent but cannot // receive their own edit id (Condition 2 returns early). Only // skip the parent when every inline child could own an id. if (!EDITABLE_HTML_TAGS.includes(childTagName)) { hasIdIneligibleInlineChild = true; } continue; } hasNonSelfClosingChild = true; if (!checkTagNameEditable(childNode, EDITABLE_HTML_TAGS)) { hasNonEditableJsxChild = true; } } } if (!hasTextContent && !hasNonSelfClosingChild && !hasInlineTagChild) return 'skip'; if (hasNonEditableJsxChild) { if (!hasTextContent && !hasInlineTagChild) return 'skip'; return 'disabled'; } if (hasNonSelfClosingChild && !hasTextContent && !hasInlineTagChild) { if (openingNode && checkTagNameEditable(openingNode, EDITABLE_HTML_TAGS)) return 'allow'; return 'skip'; } // Layout wrappers that only nest id-eligible inline tags (span/a) skip so // the child receives the id. Keep 'allow' when an inline child cannot own // an id (`

text

`), or when the parent is itself an // editable tag (h1, p, a, button, …) that should own the edit target. if (!hasTextContent && hasInlineTagChild && !hasIdIneligibleInlineChild) { const parentName = openingNode?.name?.name || openingNode?.name?.property?.name || ''; const isFormattingWrapper = INLINE_EDIT_HTML_TAGS.includes(parentName) && parentName !== 'a'; if (openingNode && checkTagNameEditable(openingNode, EDITABLE_HTML_TAGS) && !isFormattingWrapper) return 'allow'; return 'skip'; } return 'allow'; } /** * Recursively checks whether an element or any of its descendants contain * dynamic content, spread props, or conditional expressions that a direct * inline edit would silently overwrite. When true the element must be routed * through AI-assisted editing instead. * @param {import('@babel/types').JSXElement} elementNode * @returns {boolean} */ export function subtreeRequiresAssistance(elementNode) { if (!t.isJSXElement(elementNode)) return false; const opening = elementNode.openingElement; if (opening.attributes.some(attribute => t.isJSXSpreadAttribute(attribute))) { return true; } for (const child of elementNode.children ?? []) { if (t.isJSXExpressionContainer(child) && !t.isJSXEmptyExpression(child.expression)) { return true; } if (t.isJSXElement(child) && subtreeRequiresAssistance(child)) { return true; } } return false; } /** * Returns true if an ancestor JSXElement already received a target attribute. * @param {import('@babel/traverse').NodePath} openingPath * @returns {boolean} */ export function hasEditableAncestor(openingPath) { const selfElementPath = openingPath.parentPath; if (!selfElementPath?.isJSXElement()) return false; let ancestorJsxElementPath = selfElementPath.findParent(path => path.isJSXElement()); while (ancestorJsxElementPath) { const ancestorOpening = ancestorJsxElementPath.node.openingElement; if (ancestorOpening.attributes.some(attribute => t.isJSXAttribute(attribute) && (attribute.name?.name === 'data-edit-id' || attribute.name?.name === 'data-edit-assisted-id') )) { return true; } ancestorJsxElementPath = ancestorJsxElementPath.findParent(path => path.isJSXElement()); } return false; } /** * Maps a JSX opening element tag name to a semantic edit type. * @param {import('@babel/types').JSXOpeningElement} openingNode * @returns {string|null} */ export function getElementTypeFromNode(openingNode) { if (!openingNode || !openingNode.name) return null; const name = openingNode.name?.name || openingNode.name?.property?.name || ''; if (!name) return null; for (const [type, tags] of Object.entries(ELEMENT_TYPE_MAP)) { if (tags.includes(name)) return type; } return null; }