import { PublishSettings } from '../vfs/types'; import { generateTrackingScript } from '../analytics/tracking-script'; import { generateConsentBanner } from './consent-banner'; export interface HtmlProcessingOptions { publishSettings: PublishSettings; projectId: string; baseUrl: string; deploymentId: string; hasEdgeFunctions?: boolean; } /** * Injects scripts, CDN links, SEO meta tags, analytics, and compliance banner into HTML * * Note: Under construction mode is handled separately by serving a dedicated page, * not by overlaying content */ export function processHtml(html: string, options: HtmlProcessingOptions): string { const { publishSettings, projectId, baseUrl, deploymentId, hasEdgeFunctions } = options; let processed = html; // 1. Inject SEO meta tags into processed = injectSeoMetaTags(processed, publishSettings, baseUrl); // 2. Inject CDN links into processed = injectCdnLinks(processed, publishSettings); // 3. Inject head scripts into processed = injectHeadScripts(processed, publishSettings); // 4. Inject edge function interceptor into (only if project has edge functions) if (hasEdgeFunctions) { processed = injectEdgeFunctionInterceptor(processed, deploymentId); } // 5. Inject body scripts before processed = injectBodyScripts(processed, publishSettings); // 6. Inject analytics tracking (if enabled and builtin) processed = injectAnalytics(processed, deploymentId, publishSettings); // 7. Inject compliance banner (if enabled) processed = injectComplianceBanner(processed, deploymentId, publishSettings); return processed; } /** * Injects SEO meta tags into */ function injectSeoMetaTags(html: string, settings: PublishSettings, baseUrl: string): string { const { seo } = settings; if (!seo || Object.keys(seo).length === 0) { return html; } const metaTags: string[] = []; // Basic meta tags if (seo.title) { metaTags.push(`${escapeHtml(seo.title)}`); metaTags.push(``); metaTags.push(``); } if (seo.description) { metaTags.push(``); metaTags.push(``); metaTags.push(``); } if (seo.keywords && seo.keywords.length > 0) { metaTags.push(``); } // Open Graph if (seo.ogImage) { metaTags.push(``); metaTags.push(``); } metaTags.push(``); metaTags.push(``); // Twitter Card metaTags.push(``); // Canonical URL if (seo.canonical) { metaTags.push(``); } // Robots directives const robotsDirectives: string[] = []; if (seo.noIndex) robotsDirectives.push('noindex'); if (seo.noFollow) robotsDirectives.push('nofollow'); if (robotsDirectives.length > 0) { metaTags.push(``); } // Inject into return injectIntoHead(html, metaTags.join('\n ')); } /** * Injects CDN links (CSS and JS) into */ function injectCdnLinks(html: string, settings: PublishSettings): string { const enabledCdnLinks = settings.cdnLinks.filter(cdn => cdn.enabled); if (enabledCdnLinks.length === 0) { return html; } const links: string[] = []; for (const cdn of enabledCdnLinks) { if (cdn.type === 'css') { links.push(``); } else if (cdn.type === 'js') { links.push(``); } } return injectIntoHead(html, links.join('\n ')); } /** * Injects head scripts into */ function injectHeadScripts(html: string, settings: PublishSettings): string { const enabledHeadScripts = settings.headScripts.filter(script => script.enabled); if (enabledHeadScripts.length === 0) { return html; } const scripts: string[] = []; for (const script of enabledHeadScripts) { if (script.type === 'inline') { scripts.push(``); } else if (script.type === 'external') { scripts.push(``); } } return injectIntoHead(html, scripts.join('\n ')); } /** * Injects body scripts before */ function injectBodyScripts(html: string, settings: PublishSettings): string { const enabledBodyScripts = settings.bodyScripts.filter(script => script.enabled); if (enabledBodyScripts.length === 0) { return html; } const scripts: string[] = []; for (const script of enabledBodyScripts) { if (script.type === 'inline') { scripts.push(``); } else if (script.type === 'external') { scripts.push(``); } } const bodyCloseTag = ''; const bodyCloseIndex = html.lastIndexOf(bodyCloseTag); if (bodyCloseIndex === -1) { // No tag, append at the end return html + '\n' + scripts.join('\n') + '\n'; } return ( html.slice(0, bodyCloseIndex) + ' ' + scripts.join('\n ') + '\n' + html.slice(bodyCloseIndex) ); } /** * Helper to inject content into */ function injectIntoHead(html: string, content: string): string { const headCloseTag = ''; const headCloseIndex = html.indexOf(headCloseTag); if (headCloseIndex === -1) { // No tag, try to inject after or at the beginning const headOpenTag = ''; const headOpenIndex = html.indexOf(headOpenTag); if (headOpenIndex !== -1) { return ( html.slice(0, headOpenIndex + headOpenTag.length) + '\n ' + content + '\n' + html.slice(headOpenIndex + headOpenTag.length) ); } // No at all, prepend return content + '\n' + html; } return ( html.slice(0, headCloseIndex) + ' ' + content + '\n' + html.slice(headCloseIndex) ); } /** * Inject analytics tracking script (if enabled and provider is builtin) */ function injectAnalytics(html: string, deploymentId: string, settings: PublishSettings): string { if (!settings.analytics.enabled || settings.analytics.provider !== 'builtin') { return html; } // Get analytics config with token and features const { analytics } = settings; const trackingOptions = { deploymentId: deploymentId, token: analytics.token, features: { basicTracking: analytics.features?.basicTracking !== false, // Default to true heatmaps: analytics.features?.heatmaps === true, sessionRecording: analytics.features?.sessionRecording === true, performanceMetrics: analytics.features?.performanceMetrics === true, engagementTracking: analytics.features?.engagementTracking === true, customEvents: analytics.features?.customEvents === true, }, }; // If compliance is enabled and blocks analytics, wrap in consent check if (settings.compliance.enabled && settings.compliance.blockAnalytics) { const wrappedScript = ` `.trim(); return injectBeforeBodyClose(html, wrappedScript); } // Otherwise, inject directly const trackingScript = generateTrackingScript(trackingOptions); return injectBeforeBodyClose(html, trackingScript); } /** * Inject compliance/consent banner (if enabled) */ function injectComplianceBanner(html: string, deploymentId: string, settings: PublishSettings): string { if (!settings.compliance.enabled) { return html; } const banner = generateConsentBanner({ deploymentId, compliance: settings.compliance, }); return injectBeforeBodyClose(html, banner); } /** * Helper to inject content before */ function injectBeforeBodyClose(html: string, content: string): string { const bodyCloseTag = ''; const bodyCloseIndex = html.lastIndexOf(bodyCloseTag); if (bodyCloseIndex === -1) { // No tag, append at the end return html + '\n' + content + '\n'; } return ( html.slice(0, bodyCloseIndex) + content + '\n' + html.slice(bodyCloseIndex) ); } /** * Inject edge function interceptor script into * Routes fetch/form requests to edge function API endpoints */ function injectEdgeFunctionInterceptor(html: string, deploymentId: string): string { if (!deploymentId) { return html; } // Minified interceptor script for production const interceptorScript = ``; return injectIntoHead(html, interceptorScript); } /** * Escape HTML special characters */ function escapeHtml(text: string): string { const map: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }; return text.replace(/[&<>"']/g, (char) => map[char]); }