import type { ElementNode, AttributeNode, DirectiveNode } from "../types"; import type { JsxContext } from "../types"; import { toCamelCase, toJsxEventName, unwrapExpression } from "./utils"; export interface AttributeResult { /** Array of JSX attribute strings (e.g. ['class="foo"', 'onClick={handler}']) */ attrs: string[]; /** Array of spread expressions (e.g. ['{...obj}']) */ spreads: string[]; } /** * Generate JSX attributes from an ElementNode's props. * Handles static attributes, v-bind (:), v-on (@), and ref. * Skips control-flow directives (v-if, v-for, v-show, v-slot, etc.) */ export function generateAttributes(node: ElementNode, ctx: JsxContext): AttributeResult { const attrs: string[] = []; const spreads: string[] = []; let staticClass: string | null = null; let dynamicClass: string | null = null; for (const prop of node.props) { if (prop.type === 6) { // AttributeNode (static) const attrNode = prop as AttributeNode; // Collect static class separately for merging with dynamic :class if (attrNode.name === "class" && attrNode.value?.content != null) { staticClass = attrNode.value.content; continue; } const attr = generateStaticAttribute(attrNode, ctx); if (attr) attrs.push(attr); } else if (prop.type === 7) { // DirectiveNode const directive = prop as DirectiveNode; // Collect dynamic :class separately for merging with static class if ( directive.name === "bind" && directive.arg && (directive.arg as any).content === "class" ) { dynamicClass = unwrapExpression(directive.exp, ctx); continue; } const result = generateDirectiveAttribute(directive, ctx); if (result) { if (result.type === "spread") { spreads.push(result.value); } else { attrs.push(result.value); } } } } // Merge static and dynamic class into a single attribute if (staticClass != null && dynamicClass != null) { if (ctx.classMap.size > 0) { // CSS modules — generate mapped static + dynamic attrs.push(generateMergedClassWithMap(staticClass, dynamicClass, ctx)); } else { attrs.push(`class={['${staticClass}', ${dynamicClass}]}`); } } else if (staticClass != null) { if (ctx.classMap.size > 0) { attrs.push(generateStaticClassAttribute(staticClass, ctx)); } else { attrs.push(`class="${staticClass}"`); } } else if (dynamicClass != null) { attrs.push(generateDynamicClass(dynamicClass, ctx)); } return { attrs, spreads }; } function generateMergedClassWithMap( staticValue: string, dynamicExpr: string, ctx: JsxContext, ): string { const staticPart = generateStaticClassAttribute(staticValue, ctx); const dynamicPart = generateDynamicClass(dynamicExpr, ctx); // Extract the expressions from class=X format const staticExpr = staticPart.replace(/^class=/, ""); const dynamicExprClean = dynamicPart.replace(/^class=/, ""); return `class={[${staticExpr}, ${dynamicExprClean}]}`; } function generateStaticAttribute(prop: AttributeNode, ctx: JsxContext): string | null { // Strip Vue 2 `.prop` modifier from static attribute names (invalid in JSX) const name = prop.name.replace(/\.prop$/, ""); const value = prop.value?.content; // ref="x" → ref={x} if (name === "ref") { return value != null ? `ref={${value}}` : "ref={undefined}"; } // class attribute with classMap rewriting if (name === "class" && value != null && ctx.classMap.size > 0) { return generateStaticClassAttribute(value, ctx); } // Boolean attribute (no value) if (value == null) { return name; } // If value contains double quotes, use expression syntax with single quotes if (value.includes('"')) { return `${name}={'${value.replace(/'/g, "\\'")}'}`; } return `${name}="${value}"`; } function generateStaticClassAttribute(value: string, ctx: JsxContext): string { const classes = value.split(/\s+/).filter(Boolean); const mapped = classes.map((cls) => { const mapped = ctx.classMap.get(cls); return mapped ?? `"${cls}"`; }); if (mapped.length === 1) { // If the single class is a module ref (no quotes), use {expr} const single = mapped[0]; if (single.startsWith('"')) { return `class=${single}`; } return `class={${single}}`; } // Multiple classes: template literal or array join const parts = mapped.map((m) => (m.startsWith('"') ? m.slice(1, -1) : `\${${m}}`)); return `class={\`${parts.join(" ")}\`}`; } /** Directives we should skip (handled by other modules) */ const CONTROL_FLOW_DIRECTIVES = new Set([ "if", "else-if", "else", "for", "show", "slot", "model", "html", "text", "pre", "cloak", "once", "memo", ]); function generateDirectiveAttribute( directive: DirectiveNode, ctx: JsxContext, ): { type: "attr" | "spread"; value: string } | null { const { name } = directive; // Skip control-flow and other directives handled elsewhere if (CONTROL_FLOW_DIRECTIVES.has(name)) { return null; } if (name === "bind") { return generateBindDirective(directive, ctx); } if (name === "on") { return generateOnDirective(directive, ctx); } // Unknown directive — skip with no warning for now return null; } function generateBindDirective( directive: DirectiveNode, ctx: JsxContext, ): { type: "attr" | "spread"; value: string } | null { const { arg, exp, modifiers } = directive; const expr = unwrapExpression(exp, ctx); // v-bind="obj" (no arg) → spread if (!arg) { return expr ? { type: "spread", value: `{...${expr}}` } : null; } const propName = unwrapExpression(arg as any); if (!propName) return null; // :class handling if (propName === "class") { return { type: "attr", value: generateDynamicClass(expr, ctx) }; } // :style handling if (propName === "style") { return { type: "attr", value: `style={${expr}}` }; } // :ref if (propName === "ref") { return { type: "attr", value: `ref={${expr}}` }; } // :key if (propName === "key") { return { type: "attr", value: `key={${expr}}` }; } // .prop modifier → use the prop name directly // .camel modifier → camelCase let finalName = propName; if (modifiers.some((m) => (m as any).content === "camel")) { finalName = toCamelCase(finalName); } return { type: "attr", value: `${finalName}={${expr}}` }; } function generateDynamicClass(expr: string, ctx: JsxContext): string { if (ctx.classMap.size === 0) { return `class={${expr}}`; } // Try to detect object literal pattern like { active: isActive, 'text-danger': hasError } const trimmed = expr.trim(); if (trimmed.startsWith("{") && trimmed.endsWith("}")) { // Rewrite keys that exist in classMap const inner = trimmed.slice(1, -1).trim(); const entries = splitObjectEntries(inner); const rewritten = entries.map((entry) => { const colonIdx = entry.indexOf(":"); if (colonIdx === -1) return entry; let key = entry.slice(0, colonIdx).trim(); const val = entry.slice(colonIdx + 1).trim(); // Remove quotes from key if present const unquotedKey = key.replace(/^['"]|['"]$/g, ""); const mapped = ctx.classMap.get(unquotedKey); if (mapped) { return `[${mapped}]: ${val}`; } return entry; }); return `class={{${rewritten.join(", ")}}}`; } // Array or other expression — pass through return `class={${expr}}`; } /** Split top-level entries of an object literal (respecting nested braces/parens) */ function splitObjectEntries(str: string): string[] { const entries: string[] = []; let depth = 0; let current = ""; for (const ch of str) { if (ch === "{" || ch === "(" || ch === "[") depth++; else if (ch === "}" || ch === ")" || ch === "]") depth--; if (ch === "," && depth === 0) { entries.push(current.trim()); current = ""; } else { current += ch; } } if (current.trim()) entries.push(current.trim()); return entries; } function generateOnDirective( directive: DirectiveNode, ctx: JsxContext, ): { type: "attr"; value: string } | null { const { arg, exp } = directive; const expr = unwrapExpression(exp, ctx); const eventName = arg ? unwrapExpression(arg as any) : ""; if (!eventName) return null; const jsxName = toJsxEventName(eventName); if (!expr) { // @click with no handler — unlikely but handle gracefully return { type: "attr", value: `${jsxName}={() => {}}` }; } // Simple identifier or member expression: onClick={handler} // Inline expression (contains parentheses, operators, etc.): onClick={() => expr} if (isSimpleExpression(expr) || isFunctionExpression(expr)) { return { type: "attr", value: `${jsxName}={${expr}}` }; } // If it looks like a function call: @click="doSomething($event)" const body = isMultiStatement(expr) ? wrapInBlock(expr) : expr; return { type: "attr", value: `${jsxName}={($event) => ${body}}` }; } /** Check if an expression contains multiple statements (semicolons outside strings) */ function isMultiStatement(expr: string): boolean { if (!expr.includes(";")) return false; let inSingle = false; let inDouble = false; let inTemplate = false; for (let i = 0; i < expr.length; i++) { const ch = expr[i]; if (ch === "\\" && (inSingle || inDouble || inTemplate)) { i++; continue; } if (!inSingle && !inDouble && !inTemplate) { if (ch === "'") inSingle = true; else if (ch === '"') inDouble = true; else if (ch === "`") inTemplate = true; else if (ch === ";") return true; } else if (inSingle && ch === "'") inSingle = false; else if (inDouble && ch === '"') inDouble = false; else if (inTemplate && ch === "`") inTemplate = false; } return false; } /** Wrap an expression in a block body if it contains multiple statements */ function wrapInBlock(expr: string): string { if (!isMultiStatement(expr)) return expr; const trimmed = expr.trim().replace(/;$/, ""); return `{ ${trimmed}; }`; } /** Check if an expression is a simple identifier or member access (no function call or complex expression) */ function isSimpleExpression(expr: string): boolean { return /^[a-zA-Z_$][\w$.]*$/.test(expr); } /** Check if an expression is already a function (arrow function or function expression). */ function isFunctionExpression(expr: string): boolean { const trimmed = expr.trim(); if (trimmed.startsWith("function")) return true; if (trimmed.startsWith("(") && trimmed.includes("=>")) return true; if (/^[\w$]+\s*=>/.test(trimmed)) return true; return false; } /** * Format attributes and spreads into a string suitable for a JSX opening tag. * Returns empty string if no attributes. */ export function formatAttributes(result: AttributeResult): string { const all = [...result.attrs, ...result.spreads]; if (all.length === 0) return ""; return " " + all.join(" "); }