UNPKG

better-svelte-email

Version:

Svelte email renderer with Tailwind support

316 lines (315 loc) 11.3 kB
import { parse } from 'svelte/compiler'; /** * Parse Svelte 5 source code and extract all class attributes * Reference: https://svelte.dev/docs/svelte/svelte-compiler#parse */ export function parseAttributes(source) { const attributes = []; try { // Parse the Svelte file into an AST // Svelte 5 parse returns a Root node with modern AST structure const ast = parse(source); // Walk the html fragment (template portion) of the AST if (ast.html && ast.html.children) { for (const child of ast.html.children) { walkNode(child, attributes, source); } } } catch (error) { console.error('Failed to parse Svelte file:', error); throw error; } return attributes; } /** * Recursively walk Svelte 5 AST nodes to find class attributes */ function walkNode(node, attributes, source) { if (!node) return; // Svelte 5 AST structure: // - Element: HTML elements like <div>, <button> // - InlineComponent: Custom components like <Button>, <Head> // - SlotElement: <svelte:element> and other svelte: elements if (node.type === 'Element' || node.type === 'InlineComponent' || node.type === 'SlotElement' || node.type === 'Component') { const elementName = node.name || 'unknown'; // Look for class and style attribute in Svelte 5 AST const classAttr = node.attributes?.find((attr) => attr.type === 'Attribute' && attr.name === 'class'); const styleAttr = node.attributes?.find((attr) => attr.type === 'Attribute' && attr.name === 'style'); if (classAttr && classAttr.value) { // Extract class value const extractedClass = extractClassValue(classAttr, source); let extractedStyle = null; if (styleAttr && styleAttr.value) { extractedStyle = extractStyleValue(styleAttr, source); } if (extractedClass) { attributes.push({ class: { raw: extractedClass.value, start: extractedClass.start, end: extractedClass.end, elementName, isStatic: extractedClass.isStatic }, style: extractedStyle ? { raw: extractedStyle.value, start: extractedStyle.start, end: extractedStyle.end, elementName } : undefined }); } } } // Recursively process children if (node.children) { for (const child of node.children) { walkNode(child, attributes, source); } } // Handle conditional blocks (#if, #each, etc.) if (node.consequent) { if (node.consequent.children) { for (const child of node.consequent.children) { walkNode(child, attributes, source); } } } if (node.alternate) { if (node.alternate.children) { for (const child of node.alternate.children) { walkNode(child, attributes, source); } } } // Handle #each blocks if (node.body) { if (node.body.children) { for (const child of node.body.children) { walkNode(child, attributes, source); } } } } /** * Extract the actual class value from a Svelte 5 attribute node */ function extractClassValue(classAttr, source) { // Svelte 5 attribute value formats: // 1. Static string: class="text-red-500" // → value: [{ type: 'Text', data: 'text-red-500' }] // // 2. Expression: class={someVar} // → value: [{ type: 'ExpressionTag', expression: {...} }] // // 3. Mixed: class="static {dynamic} more" // → value: [{ type: 'Text' }, { type: 'ExpressionTag' }, { type: 'Text' }] if (!classAttr.value || classAttr.value.length === 0) { return null; } // Check if entirely static (only Text nodes) const hasOnlyText = classAttr.value.every((v) => v.type === 'Text'); if (hasOnlyText) { // Fully static - we can safely transform this const textContent = classAttr.value.map((v) => v.data || '').join(''); const start = classAttr.value[0].start; const end = classAttr.value[classAttr.value.length - 1].end; return { value: textContent, start, end, isStatic: true }; } // Check if entirely dynamic (only ExpressionTag or MustacheTag) const hasOnlyExpression = classAttr.value.length === 1 && (classAttr.value[0].type === 'ExpressionTag' || classAttr.value[0].type === 'MustacheTag'); if (hasOnlyExpression) { // Fully dynamic - cannot transform at build time const exprNode = classAttr.value[0]; const expressionCode = source.substring(exprNode.start, exprNode.end); return { value: expressionCode, start: exprNode.start, end: exprNode.end, isStatic: false }; } // Mixed content (both Text and ExpressionTag) // Extract only the static Text portions for partial transformation let combinedValue = ''; const start = classAttr.value[0].start; const end = classAttr.value[classAttr.value.length - 1].end; let hasStaticContent = false; for (const part of classAttr.value) { if (part.type === 'Text' && part.data) { combinedValue += part.data + ' '; hasStaticContent = true; } // Skip ExpressionTag nodes } if (hasStaticContent) { return { value: combinedValue.trim(), start, end, isStatic: false // Mixed is not fully static }; } return null; } /** * Extract the actual style value from a Svelte 5 attribute node */ function extractStyleValue(styleAttr, source) { // Svelte 5 attribute value formats: // 1. Static string: style="color: red;" // → value: [{ type: 'Text', data: 'color: red;' }] // // 2. Expression: style={someVar} // → value: [{ type: 'ExpressionTag', expression: {...} }] // // 3. Mixed: style="color: red; {dynamicStyle}" // → value: [{ type: 'Text' }, { type: 'ExpressionTag' }] if (!styleAttr.value || styleAttr.value.length === 0) { return null; } // Check if entirely static (only Text nodes) const hasOnlyText = styleAttr.value.every((v) => v.type === 'Text'); if (hasOnlyText) { // Fully static - we can extract this const textContent = styleAttr.value.map((v) => v.data || '').join(''); return { value: textContent, start: styleAttr.start, end: styleAttr.end }; } // Check if entirely dynamic (only ExpressionTag or MustacheTag) const hasOnlyExpression = styleAttr.value.length === 1 && (styleAttr.value[0].type === 'ExpressionTag' || styleAttr.value[0].type === 'MustacheTag'); if (hasOnlyExpression) { // Fully dynamic - extract the expression code const exprNode = styleAttr.value[0]; const expressionCode = source.substring(exprNode.start, exprNode.end); return { value: expressionCode, start: exprNode.start, end: exprNode.end }; } // Mixed content (both Text and ExpressionTag) // Extract the full content including dynamic parts const start = styleAttr.value[0].start; const end = styleAttr.value[styleAttr.value.length - 1].end; const fullContent = source.substring(start, end); return { value: fullContent, start: styleAttr.start, end: styleAttr.end }; } /** * Find the <Head> component in Svelte 5 AST * Returns the position where we should inject styles */ export function findHeadComponent(source) { try { const ast = parse(source); // Find Head component in the AST if (ast.html && ast.html.children) { for (const child of ast.html.children) { const headInfo = findHeadInNode(child, source); if (headInfo) return headInfo; } } return { found: false, insertPosition: null }; } catch { return { found: false, insertPosition: null }; } } /** * Recursively search for Head component in Svelte 5 AST */ function findHeadInNode(node, source) { if (!node) return null; // Check if this is the Head component (InlineComponent type in Svelte 5) if ((node.type === 'InlineComponent' || node.type === 'Component') && node.name === 'Head') { // Svelte 5: Find the best insertion point for styles // If Head has children, insert before first child if (node.children && node.children.length > 0) { return { found: true, insertPosition: node.children[0].start }; } // No children - need to insert before closing tag // Find where the opening tag ends const headStart = node.start; const headEnd = node.end; const headContent = source.substring(headStart, headEnd); // Self-closing: <Head /> if (headContent.includes('/>')) { // Convert to non-self-closing by inserting before /> const selfClosingPos = source.indexOf('/>', headStart); return { found: true, insertPosition: selfClosingPos }; } // Regular closing tag: <Head></Head> or <Head>...</Head> const closingTagPos = source.indexOf('</Head>', headStart); if (closingTagPos !== -1) { return { found: true, insertPosition: closingTagPos }; } // Fallback: insert right after opening tag const openingTagEnd = source.indexOf('>', headStart); if (openingTagEnd !== -1) { return { found: true, insertPosition: openingTagEnd + 1 }; } } // Search recursively through the AST if (node.children) { for (const child of node.children) { const found = findHeadInNode(child, source); if (found) return found; } } // Check conditional branches if (node.consequent) { if (node.consequent.children) { for (const child of node.consequent.children) { const found = findHeadInNode(child, source); if (found) return found; } } } if (node.alternate) { if (node.alternate.children) { for (const child of node.alternate.children) { const found = findHeadInNode(child, source); if (found) return found; } } } return null; }