| import { VirtualFileSystem } from '../vfs'; |
| import { VirtualFile, ProjectRuntime } from '../vfs/types'; |
| import { ProcessedFile, Route, CompiledProject } from './types'; |
| import Handlebars from 'handlebars'; |
| import { logger } from '@/lib/utils'; |
| import { beginCompilation, pushCompileError, commitCompilation } from './compile-errors'; |
| import { isRuntimeBundled } from '@/lib/runtimes/registry'; |
|
|
| export class VirtualServer { |
| private vfs: VirtualFileSystem; |
| private projectId: string; |
| private deploymentId?: string; |
| private baseUrl: string; |
| private blobUrls: Map<string, string> = new Map(); |
| private fileHashes: Map<string, string> = new Map(); |
| private handlebars: typeof Handlebars; |
| private templateCache: Map<string, HandlebarsTemplateDelegate> = new Map(); |
| private partialsRegistered: boolean = false; |
| private entryPoint: string; |
| private runtime: ProjectRuntime; |
|
|
| constructor(vfs: VirtualFileSystem, projectId: string, opts?: { deploymentId?: string; entryPoint?: string; runtime?: ProjectRuntime }) { |
| this.vfs = vfs; |
| this.projectId = projectId; |
| this.deploymentId = opts?.deploymentId; |
| this.entryPoint = opts?.entryPoint || '/index.html'; |
| this.runtime = opts?.runtime || 'handlebars'; |
| this.baseUrl = typeof window !== 'undefined' ? window.location.origin : ''; |
|
|
| |
| this.handlebars = Handlebars.create(); |
| this.registerHelpers(); |
| } |
|
|
| private registerHelpers(): void { |
| |
| this.handlebars.registerHelper('eq', (a: any, b: any) => a === b); |
| this.handlebars.registerHelper('ne', (a: any, b: any) => a !== b); |
| this.handlebars.registerHelper('lt', (a: any, b: any) => a < b); |
| this.handlebars.registerHelper('gt', (a: any, b: any) => a > b); |
| this.handlebars.registerHelper('lte', (a: any, b: any) => a <= b); |
| this.handlebars.registerHelper('gte', (a: any, b: any) => a >= b); |
| |
| |
| this.handlebars.registerHelper('and', (...helperArgs: unknown[]) => { |
| |
| return helperArgs.slice(0, -1).every((arg) => arg); |
| }); |
| this.handlebars.registerHelper('or', (...helperArgs: unknown[]) => { |
| return helperArgs.slice(0, -1).some((arg) => arg); |
| }); |
| this.handlebars.registerHelper('not', (value: any) => !value); |
| |
| |
| this.handlebars.registerHelper('add', (a: number, b: number) => a + b); |
| this.handlebars.registerHelper('subtract', (a: number, b: number) => a - b); |
| this.handlebars.registerHelper('multiply', (a: number, b: number) => a * b); |
| this.handlebars.registerHelper('divide', (a: number, b: number) => a / b); |
| |
| |
| this.handlebars.registerHelper('uppercase', (str: string) => str?.toUpperCase()); |
| this.handlebars.registerHelper('lowercase', (str: string) => str?.toLowerCase()); |
| this.handlebars.registerHelper('concat', (...helperArgs: unknown[]) => { |
| return helperArgs.slice(0, -1).join(''); |
| }); |
| |
| |
| this.handlebars.registerHelper('json', (context: any) => JSON.stringify(context, null, 2)); |
| this.handlebars.registerHelper('formatDate', (date: Date | string) => { |
| const d = new Date(date); |
| return d.toLocaleDateString(); |
| }); |
|
|
| |
| this.handlebars.registerHelper('limit', (array: any[], max: number) => |
| array?.slice(0, max) |
| ); |
|
|
| |
| const repeatHelper = function(this: any, n: number, options: Handlebars.HelperOptions) { |
| let result = ''; |
| for (let i = 0; i < n; i++) { |
| result += options.fn({ index: i, first: i === 0, last: i === n - 1 }); |
| } |
| return result; |
| }; |
| this.handlebars.registerHelper('times', repeatHelper); |
| this.handlebars.registerHelper('repeat', repeatHelper); |
| this.handlebars.registerHelper('for', repeatHelper); |
| } |
|
|
| private async registerPartials(): Promise<void> { |
| if (this.partialsRegistered) { |
| return; |
| } |
|
|
| try { |
| |
| const allItems = await this.vfs.getAllFilesAndDirectories(this.projectId); |
|
|
| |
| const templateFiles = allItems.filter((item): item is VirtualFile => |
| 'content' in item && |
| item.path.startsWith('/templates/') && |
| (item.path.endsWith('.hbs') || item.path.endsWith('.handlebars')) |
| ); |
|
|
| for (const file of templateFiles) { |
| const content = file.content as string; |
|
|
| |
| |
| const relativePath = file.path |
| .replace(/^\/templates\//, '') |
| .replace(/\.hbs$/, '') |
| .replace(/\.handlebars$/, ''); |
|
|
| |
|
|
| |
| this.handlebars.registerPartial(relativePath, content); |
|
|
| |
| const filename = relativePath.split('/').pop(); |
| if (filename) { |
| this.handlebars.registerPartial(filename, content); |
| } |
|
|
| |
| if (relativePath.includes('/')) { |
| const dashName = relativePath.replace(/\//g, '-'); |
| this.handlebars.registerPartial(dashName, content); |
| } |
| } |
|
|
| this.partialsRegistered = true; |
| } catch (error) { |
| |
| } |
| } |
|
|
| private async compileTemplate(templatePath: string, context: any = {}): Promise<string> { |
| |
| let compiled = this.templateCache.get(templatePath); |
| |
| if (!compiled) { |
| try { |
| const file = await this.vfs.readFile(this.projectId, templatePath); |
| const templateContent = file.content as string; |
| compiled = this.handlebars.compile(templateContent); |
| this.templateCache.set(templatePath, compiled); |
| } catch (error) { |
| logger.error(`Failed to compile template ${templatePath}:`, error); |
| return ''; |
| } |
| } |
| |
| return compiled(context); |
| } |
|
|
| async compileProject(incrementalUpdate = false): Promise<CompiledProject> { |
| beginCompilation(); |
| try { |
| |
| this.vfs.clearGeneratedFiles(); |
|
|
| |
| await this.registerPartials(); |
|
|
| let files = await this.vfs.listDirectory(this.projectId, '/'); |
| files = await this.runBundleStep(files); |
|
|
| const oldBlobUrls = new Map(this.blobUrls); |
| const newBlobUrls = new Map<string, string>(); |
| const rawProcessedFiles: ProcessedFile[] = []; |
| |
| |
| for (const file of files) { |
| let processedFile: ProcessedFile; |
| |
| |
| if (file.type === 'template' || file.type === 'html' || file.type === 'css') { |
| continue; |
| } |
| |
| if (file.type === 'image' || file.type === 'video') { |
| processedFile = { |
| path: file.path, |
| content: file.content, |
| mimeType: file.mimeType |
| }; |
| } else { |
| processedFile = { |
| path: file.path, |
| content: file.content as string, |
| mimeType: file.mimeType |
| }; |
| } |
| |
| const contentHash = this.hashContent(processedFile.content); |
| const previousHash = this.fileHashes.get(processedFile.path); |
| |
| if (incrementalUpdate && previousHash === contentHash && oldBlobUrls.has(processedFile.path)) { |
| const existingUrl = oldBlobUrls.get(processedFile.path)!; |
| newBlobUrls.set(processedFile.path, existingUrl); |
| processedFile.blobUrl = existingUrl; |
| oldBlobUrls.delete(processedFile.path); |
| } else { |
| const blob = new Blob([processedFile.content], { type: processedFile.mimeType }); |
| const blobUrl = URL.createObjectURL(blob); |
| newBlobUrls.set(processedFile.path, blobUrl); |
| processedFile.blobUrl = blobUrl; |
| this.fileHashes.set(processedFile.path, contentHash); |
| } |
| |
| rawProcessedFiles.push(processedFile); |
| } |
| |
| |
| for (const file of files) { |
| if (file.type !== 'html') { |
| continue; |
| } |
| |
| const processedFile = await this.processHTML(file, newBlobUrls); |
| |
| const contentHash = this.hashContent(processedFile.content); |
| const previousHash = this.fileHashes.get(processedFile.path); |
| |
| if (incrementalUpdate && previousHash === contentHash && oldBlobUrls.has(processedFile.path)) { |
| const existingUrl = oldBlobUrls.get(processedFile.path)!; |
| newBlobUrls.set(processedFile.path, existingUrl); |
| processedFile.blobUrl = existingUrl; |
| oldBlobUrls.delete(processedFile.path); |
| } else { |
| const blob = new Blob([processedFile.content], { type: processedFile.mimeType }); |
| const blobUrl = URL.createObjectURL(blob); |
| newBlobUrls.set(processedFile.path, blobUrl); |
| processedFile.blobUrl = blobUrl; |
| this.fileHashes.set(processedFile.path, contentHash); |
| } |
| |
| rawProcessedFiles.push(processedFile); |
| } |
| |
| const processedFiles = [...rawProcessedFiles]; |
| for (const file of files) { |
| if (file.type === 'css') { |
| const processedFile = await this.processCSS(file, newBlobUrls); |
| |
| const contentHash = this.hashContent(processedFile.content); |
| const previousHash = this.fileHashes.get(processedFile.path); |
| |
| if (incrementalUpdate && previousHash === contentHash && oldBlobUrls.has(processedFile.path)) { |
| const existingUrl = oldBlobUrls.get(processedFile.path)!; |
| newBlobUrls.set(processedFile.path, existingUrl); |
| processedFile.blobUrl = existingUrl; |
| oldBlobUrls.delete(processedFile.path); |
| } else { |
| const blob = new Blob([processedFile.content], { type: processedFile.mimeType }); |
| const blobUrl = URL.createObjectURL(blob); |
| newBlobUrls.set(processedFile.path, blobUrl); |
| processedFile.blobUrl = blobUrl; |
| this.fileHashes.set(processedFile.path, contentHash); |
| } |
| |
| processedFiles.push(processedFile); |
| } |
| } |
| |
| const routes = this.generateRoutes(files); |
| |
| if (incrementalUpdate) { |
| for (const [, url] of oldBlobUrls) { |
| URL.revokeObjectURL(url); |
| } |
| } else if (!incrementalUpdate) { |
| this.cleanupBlobUrls(); |
| } |
| |
| this.blobUrls = newBlobUrls; |
|
|
| return { |
| entryPoint: this.entryPoint, |
| files: processedFiles, |
| routes, |
| blobUrls: this.blobUrls |
| }; |
|
|
| } finally { |
| commitCompilation(); |
| } |
| } |
| |
| private async runBundleStep(files: VirtualFile[]): Promise<VirtualFile[]> { |
| if (!isRuntimeBundled(this.runtime)) return files; |
|
|
| |
| |
| |
| const hasBundle = files.some(f => f.path === '/bundle.js'); |
| const hasSourceFiles = files.some(f => /\.(tsx|ts|jsx|svelte|vue)$/.test(f.path) && !f.path.startsWith('/.')); |
| if (hasBundle && !hasSourceFiles) { |
| return files.filter(f => !/\.(tsx|ts|jsx|svelte|vue)$/.test(f.path)); |
| } |
|
|
| |
| const { detectBundleEntryPoint, bundleProject, isBundleableSource } = |
| await import('./esbuild-bundler'); |
|
|
| const entryPoint = detectBundleEntryPoint(files); |
| if (!entryPoint) return files; |
|
|
| const result = await bundleProject({ files, entryPoint, runtime: this.runtime }); |
|
|
| |
| for (const err of result.errors) { |
| pushCompileError(entryPoint, err); |
| } |
|
|
| if (result.errors.length > 0) { |
| |
| this.vfs.clearGeneratedFiles(); |
| return files; |
| } |
|
|
| |
| const filtered = files.filter(f => !isBundleableSource(f.path)); |
|
|
| |
| const now = new Date(); |
| filtered.push({ |
| id: '__bundle_js__', |
| projectId: this.projectId, |
| path: '/bundle.js', |
| name: 'bundle.js', |
| type: 'js', |
| content: result.js, |
| mimeType: 'application/javascript', |
| size: result.js.length, |
| createdAt: now, |
| updatedAt: now, |
| metadata: { isTransient: true }, |
| }); |
|
|
| |
| |
| const cssContent = result.css || ''; |
| filtered.push({ |
| id: '__bundle_css__', |
| projectId: this.projectId, |
| path: '/bundle.css', |
| name: 'bundle.css', |
| type: 'css', |
| content: cssContent, |
| mimeType: 'text/css', |
| size: cssContent.length, |
| createdAt: now, |
| updatedAt: now, |
| metadata: { isTransient: true }, |
| }); |
|
|
| |
| this.vfs.setGeneratedFile('/bundle.js', result.js, 'application/javascript'); |
| this.vfs.setGeneratedFile('/bundle.css', cssContent, 'text/css'); |
|
|
| return filtered; |
| } |
|
|
| private hashContent(content: string | ArrayBuffer): string { |
| let hash = 0; |
| |
| if (content instanceof ArrayBuffer) { |
| const view = new Uint8Array(content); |
| for (let i = 0; i < Math.min(view.length, 10000); i++) { |
| hash = ((hash << 5) - hash) + view[i]; |
| hash = hash & hash; |
| } |
| } else { |
| for (let i = 0; i < content.length; i++) { |
| const char = content.charCodeAt(i); |
| hash = ((hash << 5) - hash) + char; |
| hash = hash & hash; |
| } |
| } |
| |
| return hash.toString(36); |
| } |
|
|
| private async processHTML(file: VirtualFile, blobUrls?: Map<string, string>): Promise<ProcessedFile> { |
| let content = file.content as string; |
|
|
| |
| if (this.runtime === 'handlebars' && !file.path.startsWith('/output/')) { |
| content = await this.processHandlebarsTemplates(content, file.path); |
| } |
| |
| |
| content = await this.processInternalReferences(content, blobUrls); |
| |
| |
| |
| const blobUrlMap = blobUrls ? Object.fromEntries(blobUrls) : {}; |
| const deploymentIdForScript = this.deploymentId || ''; |
| const vfsScript = `<script> |
| // VFS Asset Interceptor - Auto-injected by OSW Studio |
| (function() { |
| const vfsBlobUrls = ${JSON.stringify(blobUrlMap)}; |
| const deploymentId = ${JSON.stringify(deploymentIdForScript)}; |
| |
| // Helper function to resolve VFS paths to blob URLs |
| function resolveVfsUrl(url) { |
| if (!url || typeof url !== 'string') return url; |
| // Prefer the complete runtime map injected by the preview host. The baked |
| // vfsBlobUrls map only holds files processed before this page, so it can miss |
| // component files fetched at runtime (e.g. fetch('/components/nav.html')). |
| try { |
| if (window.__oswVfsBlobUrls && window.__oswVfsBlobUrls[url]) { |
| return window.__oswVfsBlobUrls[url]; |
| } |
| } catch {} |
| if (vfsBlobUrls[url]) { |
| return vfsBlobUrls[url]; |
| } |
| return url; |
| } |
| |
| // Helper function to check if a URL looks like an edge function call |
| function isEdgeFunctionUrl(url) { |
| if (!url || typeof url !== 'string' || !deploymentId) return false; |
| // Skip external URLs, blob URLs, data URLs, and hash-only URLs |
| if (url.startsWith('http://') || url.startsWith('https://') || |
| url.startsWith('blob:') || url.startsWith('data:') || |
| url.startsWith('//') || url.startsWith('#')) { |
| return false; |
| } |
| // Skip if already an API path |
| if (url.startsWith('/api/')) return false; |
| // Skip if it has a file extension (likely an asset) |
| const pathWithoutQuery = url.split('?')[0].split('#')[0]; |
| const lastSegment = pathWithoutQuery.split('/').pop() || ''; |
| if (lastSegment.includes('.')) return false; |
| // This looks like an edge function path |
| return true; |
| } |
| |
| // Helper function to convert an edge function URL to the API endpoint |
| function toEdgeFunctionApiUrl(url) { |
| if (!deploymentId) return url; |
| // Normalize the path |
| let path = url; |
| if (!path.startsWith('/')) path = '/' + path; |
| // Remove leading slash for the function name |
| const functionPath = path.substring(1); |
| // Return the API endpoint URL |
| return '/api/deployments/' + deploymentId + '/functions/' + functionPath; |
| } |
| |
| // Intercept Image src setter to handle ALL image loading |
| const originalSrcDescriptor = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src'); |
| Object.defineProperty(HTMLImageElement.prototype, 'src', { |
| get: function() { |
| return originalSrcDescriptor.get.call(this); |
| }, |
| set: function(value) { |
| const resolvedUrl = resolveVfsUrl(value); |
| return originalSrcDescriptor.set.call(this, resolvedUrl); |
| }, |
| enumerable: true, |
| configurable: true |
| }); |
| |
| // Intercept setAttribute for src attributes |
| const originalSetAttribute = Element.prototype.setAttribute; |
| Element.prototype.setAttribute = function(name, value) { |
| if ((name === 'src' || name === 'href') && this instanceof HTMLImageElement) { |
| value = resolveVfsUrl(value); |
| } |
| return originalSetAttribute.call(this, name, value); |
| }; |
| |
| // Intercept innerHTML to catch template-generated images |
| const originalInnerHTMLDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML'); |
| Object.defineProperty(Element.prototype, 'innerHTML', { |
| get: function() { |
| return originalInnerHTMLDescriptor.get.call(this); |
| }, |
| set: function(value) { |
| if (typeof value === 'string' && value.includes('/assets/')) { |
| // Replace asset URLs in the HTML string before setting |
| const srcRegex = new RegExp('src=["\\']([^"\\']*/assets/[^"\\']*)["\\']', 'g'); |
| value = value.replace(srcRegex, function(match, url) { |
| const resolvedUrl = resolveVfsUrl(url); |
| if (resolvedUrl !== url) { |
| return match.replace(url, resolvedUrl); |
| } |
| return match; |
| }); |
| } |
| return originalInnerHTMLDescriptor.set.call(this, value); |
| }, |
| enumerable: true, |
| configurable: true |
| }); |
| |
| // Intercept Image constructor |
| const OriginalImage = window.Image; |
| window.Image = function(...args) { |
| const img = new OriginalImage(...args); |
| // Override src setter for this instance too |
| const descriptor = Object.getOwnPropertyDescriptor(img, 'src') || |
| Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src'); |
| if (descriptor) { |
| Object.defineProperty(img, 'src', { |
| get: descriptor.get, |
| set: function(value) { |
| const resolvedUrl = resolveVfsUrl(value); |
| return originalSrcDescriptor.set.call(this, resolvedUrl); |
| }, |
| enumerable: true, |
| configurable: true |
| }); |
| } |
| return img; |
| }; |
| // Preserve original Image properties |
| Object.setPrototypeOf(window.Image, OriginalImage); |
| window.Image.prototype = OriginalImage.prototype; |
| |
| // Intercept createElement for img elements |
| const originalCreateElement = document.createElement; |
| document.createElement = function(tagName, options) { |
| const element = originalCreateElement.call(this, tagName, options); |
| if (tagName.toLowerCase() === 'img') { |
| const originalSrcDescriptor = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src'); |
| Object.defineProperty(element, 'src', { |
| get: function() { |
| return originalSrcDescriptor.get.call(this); |
| }, |
| set: function(value) { |
| const resolvedUrl = resolveVfsUrl(value); |
| return originalSrcDescriptor.set.call(this, resolvedUrl); |
| }, |
| enumerable: true, |
| configurable: true |
| }); |
| } |
| return element; |
| }; |
| |
| // Intercept fetch requests to VFS assets and edge functions |
| const originalFetch = window.fetch; |
| window.fetch = function(input, init) { |
| const url = typeof input === 'string' ? input : input.url; |
| |
| // First check if this is an edge function call |
| if (isEdgeFunctionUrl(url)) { |
| const apiUrl = toEdgeFunctionApiUrl(url); |
| // Use the parent window's origin for the API call |
| const fullApiUrl = window.parent ? window.parent.location.origin + apiUrl : apiUrl; |
| return originalFetch(fullApiUrl, init); |
| } |
| |
| // Then check for VFS asset resolution |
| const resolvedUrl = resolveVfsUrl(url); |
| if (resolvedUrl !== url) { |
| return originalFetch(resolvedUrl, init); |
| } |
| |
| return originalFetch(input, init); |
| }; |
| |
| // Intercept XMLHttpRequest for older code and edge functions |
| const OriginalXHR = window.XMLHttpRequest; |
| window.XMLHttpRequest = function() { |
| const xhr = new OriginalXHR(); |
| const originalOpen = xhr.open; |
| |
| xhr.open = function(method, url, ...args) { |
| let finalUrl = url; |
| |
| // Check for edge function first |
| if (isEdgeFunctionUrl(url)) { |
| const apiUrl = toEdgeFunctionApiUrl(url); |
| finalUrl = window.parent ? window.parent.location.origin + apiUrl : apiUrl; |
| } else { |
| finalUrl = resolveVfsUrl(url); |
| } |
| |
| return originalOpen.call(this, method, finalUrl, ...args); |
| }; |
| |
| return xhr; |
| }; |
| |
| // Intercept form submissions for edge functions |
| if (deploymentId) { |
| document.addEventListener('submit', function(e) { |
| const form = e.target; |
| if (!(form instanceof HTMLFormElement)) return; |
| |
| const action = form.getAttribute('action') || ''; |
| if (isEdgeFunctionUrl(action)) { |
| e.preventDefault(); |
| e.stopPropagation(); |
| |
| const apiUrl = toEdgeFunctionApiUrl(action); |
| const fullApiUrl = window.parent ? window.parent.location.origin + apiUrl : apiUrl; |
| const method = (form.method || 'GET').toUpperCase(); |
| |
| // Collect form data |
| const formData = new FormData(form); |
| |
| // Convert to JSON for edge functions |
| const data = {}; |
| formData.forEach(function(value, key) { |
| data[key] = value; |
| }); |
| |
| // Make the fetch request |
| fetch(fullApiUrl, { |
| method: method, |
| headers: { |
| 'Content-Type': 'application/json' |
| }, |
| body: method !== 'GET' ? JSON.stringify(data) : undefined |
| }) |
| .then(function(response) { |
| return response.json().catch(function() { |
| return response.text(); |
| }); |
| }) |
| .then(function(result) { |
| // Dispatch custom event with the result |
| const event = new CustomEvent('edge-function-response', { |
| detail: { action: action, result: result } |
| }); |
| form.dispatchEvent(event); |
| document.dispatchEvent(event); |
| |
| }) |
| .catch(function(error) { |
| console.error('[Edge Function] Error:', error); |
| const event = new CustomEvent('edge-function-error', { |
| detail: { action: action, error: error.message } |
| }); |
| form.dispatchEvent(event); |
| document.dispatchEvent(event); |
| }); |
| } |
| }, true); |
| } |
| |
| // Process any existing images in the DOM when ready |
| function processExistingImages() { |
| const images = document.querySelectorAll('img[src*="/assets/"]'); |
| images.forEach(img => { |
| const currentSrc = img.src; |
| const resolvedSrc = resolveVfsUrl(currentSrc); |
| if (resolvedSrc !== currentSrc) { |
| img.src = resolvedSrc; |
| } |
| }); |
| } |
| |
| // Use MutationObserver to catch dynamically added images |
| function setupMutationObserver() { |
| if (typeof MutationObserver !== 'undefined') { |
| const observer = new MutationObserver(function(mutations) { |
| mutations.forEach(function(mutation) { |
| mutation.addedNodes.forEach(function(node) { |
| if (node.nodeType === 1) { // Element node |
| if (node.tagName === 'IMG' && node.src && node.src.includes('/assets/')) { |
| const resolvedSrc = resolveVfsUrl(node.src); |
| if (resolvedSrc !== node.src) { |
| node.src = resolvedSrc; |
| } |
| } |
| // Also check children |
| const childImages = node.querySelectorAll && node.querySelectorAll('img[src*="/assets/"]'); |
| if (childImages) { |
| childImages.forEach(img => { |
| const resolvedSrc = resolveVfsUrl(img.src); |
| if (resolvedSrc !== img.src) { |
| img.src = resolvedSrc; |
| } |
| }); |
| } |
| } |
| }); |
| }); |
| }); |
| |
| observer.observe(document.body || document.documentElement, { |
| childList: true, |
| subtree: true |
| }); |
| } |
| } |
| |
| // Setup everything when DOM is ready |
| if (document.readyState === 'loading') { |
| document.addEventListener('DOMContentLoaded', function() { |
| processExistingImages(); |
| setupMutationObserver(); |
| }); |
| } else { |
| processExistingImages(); |
| setupMutationObserver(); |
| } |
| })(); |
| </script>`; |
|
|
| const consoleScript = `<script> |
| // Console Capture - Auto-injected by OSW Studio |
| (function() { |
| if (window === window.parent) return; |
| var levels = ['log', 'warn', 'error', 'info', 'debug']; |
| var originals = {}; |
| var queue = []; |
| var timer = null; |
| |
| function serialize(arg) { |
| if (typeof arg === 'string') return arg; |
| if (arg === null) return 'null'; |
| if (arg === undefined) return 'undefined'; |
| if (arg instanceof Error) return arg.stack || arg.message || String(arg); |
| if (typeof arg === 'function') return 'function ' + (arg.name || 'anonymous') + '()'; |
| if (typeof HTMLElement !== 'undefined' && arg instanceof HTMLElement) return '<' + arg.tagName.toLowerCase() + '>'; |
| try { |
| var seen = []; |
| return JSON.stringify(arg, function(key, val) { |
| if (typeof val === 'object' && val !== null) { |
| if (seen.indexOf(val) !== -1) return '[Circular]'; |
| seen.push(val); |
| } |
| return val; |
| }); |
| } catch (e) { |
| return String(arg); |
| } |
| } |
| |
| function flush() { |
| timer = null; |
| if (queue.length === 0) return; |
| var batch = queue.splice(0, 50); |
| for (var i = 0; i < batch.length; i++) { |
| try { |
| window.parent.postMessage(batch[i], '*'); |
| } catch (e) {} |
| } |
| if (queue.length > 0) { |
| timer = setTimeout(flush, 100); |
| } |
| } |
| |
| for (var i = 0; i < levels.length; i++) { |
| (function(level) { |
| originals[level] = console[level]; |
| console[level] = function() { |
| originals[level].apply(console, arguments); |
| var args = []; |
| for (var j = 0; j < arguments.length; j++) { |
| args.push(serialize(arguments[j])); |
| } |
| queue.push({ type: 'console', level: level, args: args }); |
| if (!timer) { |
| timer = setTimeout(flush, 100); |
| } |
| }; |
| })(levels[i]); |
| } |
| |
| // Capture uncaught errors (SyntaxError, ReferenceError, etc.) |
| window.onerror = function(message, source, lineno, colno) { |
| var loc = source ? ' (' + source.replace(/^.*[\\/]/, '') + ':' + lineno + ':' + colno + ')' : ''; |
| queue.push({ type: 'console', level: 'error', args: [String(message) + loc] }); |
| if (!timer) timer = setTimeout(flush, 100); |
| }; |
| |
| // Capture unhandled promise rejections |
| window.addEventListener('unhandledrejection', function(e) { |
| var reason = e.reason; |
| var msg = reason instanceof Error ? (reason.stack || reason.message) : String(reason); |
| queue.push({ type: 'console', level: 'error', args: ['Unhandled rejection: ' + msg] }); |
| if (!timer) timer = setTimeout(flush, 100); |
| }); |
| })(); |
| </script>`; |
|
|
| |
| |
| let importMapScript = ''; |
| if (!isRuntimeBundled(this.runtime) && blobUrls) { |
| const imports: Record<string, string> = {}; |
| for (const [path, url] of blobUrls) { |
| if (/\.(js|mjs|ts)$/i.test(path)) { |
| imports[path] = url; |
| if (path.startsWith('/')) imports['.' + path] = url; |
| } |
| } |
| if (Object.keys(imports).length > 0) { |
| importMapScript = `<script type="importmap">${JSON.stringify({ imports })}</script>\n`; |
| } |
| } |
|
|
| |
| |
| const injectedScripts = importMapScript + vfsScript + '\n' + consoleScript; |
| if (content.includes('</head>')) { |
| content = content.replace('</head>', injectedScripts + '\n</head>'); |
| } else if (content.includes('<body>')) { |
| content = content.replace('<body>', injectedScripts + '\n<body>'); |
| } else { |
| content = injectedScripts + '\n' + content; |
| } |
|
|
| return { |
| path: file.path, |
| content, |
| mimeType: file.mimeType |
| }; |
| } |
| |
| private extractPartialReferences(content: string): string[] { |
| |
| const partialRegex = /\{\{>\s*([\w-]+)\s*(?:\s+[^}]*)?\}\}/g; |
| const partials = new Set<string>(); |
| let match; |
|
|
| while ((match = partialRegex.exec(content)) !== null) { |
| partials.add(match[1]); |
| } |
|
|
| return Array.from(partials); |
| } |
|
|
| private registerErrorStubsForMissingPartials(partialRefs: string[]): void { |
| for (const partialName of partialRefs) { |
| |
| if (!this.handlebars.partials[partialName]) { |
| |
| const errorStub = `<div style="border: 2px solid #f99; background: #fee; padding: 1rem; margin: 1rem 0; border-radius: 4px; font-family: monospace;"> |
| <strong style="color: #c33;">⚠️ Missing partial: "${partialName}"</strong> |
| <p style="margin: 0.5rem 0 0 0; font-size: 0.9em;">Create file in /templates/ directory (e.g., /templates/${partialName}.hbs or /templates/components/${partialName}.hbs)</p> |
| </div>`; |
| this.handlebars.registerPartial(partialName, errorStub); |
| } |
| } |
| } |
|
|
| private async processHandlebarsTemplates(content: string, filePath?: string): Promise<string> { |
| |
| await this.registerPartials(); |
|
|
| try { |
| |
| const invalidPatterns = this.detectInvalidHandlebarsPatterns(content); |
| if (invalidPatterns.length > 0) { |
| for (const p of invalidPatterns) { |
| pushCompileError(filePath || 'unknown', `${p.error} — ${p.suggestion}`); |
| } |
| const errorMessages = invalidPatterns.map(pattern => `❌ ${pattern.error}\n💡 ${pattern.suggestion}`).join('\n\n'); |
| return `<!-- Handlebars Syntax Error -->\n<div style="background: #fee; border: 1px solid #f99; padding: 1rem; margin: 1rem; border-radius: 4px; font-family: monospace;">\n<h3 style="color: #c33; margin: 0 0 1rem 0;">⚠️ Handlebars Template Error</h3>\n<pre style="margin: 0; white-space: pre-wrap;">${errorMessages}</pre>\n</div>\n<!-- Original content:\n${content}\n-->`; |
| } |
|
|
| |
| const partialRefs = this.extractPartialReferences(content); |
| this.registerErrorStubsForMissingPartials(partialRefs); |
|
|
| |
| let context = {}; |
| try { |
| if (await this.vfs.fileExists(this.projectId, '/data.json')) { |
| const dataFile = await this.vfs.readFile(this.projectId, '/data.json'); |
| context = JSON.parse(dataFile.content as string); |
| } |
| } catch { |
| |
| } |
|
|
| |
| const template = this.handlebars.compile(content); |
| const result = template(context); |
| return result; |
| } catch (error) { |
| logger.error('VirtualServer: Error processing Handlebars templates:', error); |
|
|
| const errorMessage = error instanceof Error ? error.message : String(error); |
| pushCompileError(filePath || 'unknown', errorMessage); |
|
|
| |
| return `<!-- Handlebars Compilation Error -->\n<div style="background: #fee; border: 1px solid #f99; padding: 1rem; margin: 1rem; border-radius: 4px; font-family: monospace;">\n<h3 style="color: #c33; margin: 0 0 1rem 0;">⚠️ Handlebars Template Error</h3>\n<p><strong>Error:</strong> ${errorMessage}</p>\n<p><strong>Common fixes:</strong></p>\n<ul>\n<li>Check for typos in helper names and partial references</li>\n<li>Ensure all opening tags have matching closing tags</li>\n<li>Verify partial names exist in /templates/ directory</li>\n<li>Use <code>{{> partialName}}</code> syntax, not <code>(> partialName)</code></li>\n</ul>\n</div>\n<!-- Original content:\n${content}\n-->`; |
| } |
| } |
|
|
| private detectInvalidHandlebarsPatterns(content: string): Array<{error: string, suggestion: string}> { |
| const patterns = []; |
| |
| |
| const invalidPartialInParam = /\w+\s*=\s*\(\s*>\s*[\w-]+\s*\)/g; |
| if (invalidPartialInParam.test(content)) { |
| patterns.push({ |
| error: "Invalid syntax: Using (> partial) as parameter value", |
| suggestion: "Use string-based dynamic partials: content=\"partial-name\" then {{> (lookup this 'content')}}" |
| }); |
| } |
| |
| |
| const typoPartialSyntax = /\{\{\s*>\s*\(\s*>\s*[\w-]+\s*\)\s*\}\}/g; |
| if (typoPartialSyntax.test(content)) { |
| patterns.push({ |
| error: "Invalid syntax: Double partial reference {{> (> partial)}}", |
| suggestion: "Use {{> partialName}} for static partials or {{> (lookup data 'partialName')}} for dynamic" |
| }); |
| } |
| |
| |
| const unquotedParams = /\{\{\s*>\s*[\w-]+\s+\w+\s*=\s*[^"'\s}][^}]*\s[^}]*(?:\s|}})/g; |
| if (unquotedParams.test(content)) { |
| patterns.push({ |
| error: "Missing quotes in parameter values", |
| suggestion: "Wrap parameter values in quotes: title=\"My Title\" not title=My Title" |
| }); |
| } |
| |
| return patterns; |
| } |
|
|
| private async processCSS(file: VirtualFile, blobUrls: Map<string, string>): Promise<ProcessedFile> { |
| let content = file.content as string; |
|
|
| content = await this.processUrlReferences(content, blobUrls); |
|
|
| return { |
| path: file.path, |
| content, |
| mimeType: file.mimeType |
| }; |
| } |
|
|
| private isAssetReference(url: string): boolean { |
| |
| const assetExtensions = [ |
| '.css', '.js', '.jsx', '.ts', '.tsx', |
| '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.webp', |
| '.woff', '.woff2', '.ttf', '.otf', '.eot', |
| '.mp4', '.webm', '.ogg', '.mp3', '.wav', |
| '.pdf', '.zip', '.json', '.xml' |
| ]; |
| |
| |
| const cleanUrl = url.split('?')[0].split('#')[0]; |
| const extension = cleanUrl.substring(cleanUrl.lastIndexOf('.')).toLowerCase(); |
| |
| return assetExtensions.includes(extension); |
| } |
|
|
| private async processInternalReferences(content: string, blobUrls?: Map<string, string>): Promise<string> { |
| const files = await this.vfs.listDirectory(this.projectId, '/'); |
| |
| |
| const urlMap = blobUrls || this.blobUrls; |
| |
| const patterns = [ |
| /href="([^"]+)"/g, |
| /src="([^"]+)"/g, |
| /href='([^']+)'/g, |
| /src='([^']+)'/g |
| ]; |
|
|
| let processed = content; |
| for (const pattern of patterns) { |
| processed = processed.replace(pattern, (match, url) => { |
| if (url.startsWith('http') || url.startsWith('data:') || url.startsWith('//') || url.startsWith('blob:') || url.startsWith('#')) { |
| return match; |
| } |
|
|
| |
| |
| const isHref = match.includes('href='); |
| if (isHref && !this.isAssetReference(url)) { |
| return match; |
| } |
|
|
| const normalizedPath = this.normalizePath(url); |
| |
| const fileExists = files.some(f => f.path === normalizedPath); |
| if (fileExists) { |
| |
| const blobUrl = urlMap.get(normalizedPath); |
| if (blobUrl) { |
| |
| return match.replace(url, blobUrl); |
| } |
| } |
|
|
| return match; |
| }); |
| } |
|
|
| return processed; |
| } |
|
|
| private async processUrlReferences(content: string, blobUrls: Map<string, string>): Promise<string> { |
| return content.replace(/url\(['"]?([^'")]+)['"]?\)/g, (match, url) => { |
| if (url.startsWith('http') || url.startsWith('data:') || url.startsWith('//') || url.startsWith('blob:')) { |
| return match; |
| } |
|
|
| const normalizedPath = this.normalizePath(url); |
| |
| const blobUrl = blobUrls.get(normalizedPath); |
| if (blobUrl) { |
| return `url('${blobUrl}')`; |
| } |
|
|
| return match; |
| }); |
| } |
|
|
| private normalizePath(path: string): string { |
| if (path.startsWith('./')) { |
| path = path.slice(2); |
| } |
|
|
| if (!path.startsWith('/')) { |
| path = '/' + path; |
| } |
|
|
| |
| if (path.endsWith('/')) { |
| return path + 'index.html'; |
| } |
|
|
| |
| if (!path.includes('.')) { |
| return path + '.html'; |
| } |
|
|
| return path; |
| } |
|
|
| private generateRoutes(files: VirtualFile[]): Route[] { |
| const htmlFiles = files.filter(f => f.type === 'html'); |
| |
| return htmlFiles.map(file => { |
| const content = file.content as string; |
| const titleMatch = content.match(/<title>([^<]+)<\/title>/i); |
| const title = titleMatch ? titleMatch[1] : file.name.replace('.html', ''); |
|
|
| const routePath = file.path.replace('.html', '') || '/'; |
|
|
| return { |
| path: routePath === '/index' ? '/' : routePath, |
| file: file.path, |
| title |
| }; |
| }); |
| } |
|
|
| cleanupBlobUrls(): void { |
| for (const url of this.blobUrls.values()) { |
| URL.revokeObjectURL(url); |
| } |
| this.blobUrls.clear(); |
| |
| |
| this.templateCache.clear(); |
| this.partialsRegistered = false; |
| } |
|
|
| async getCompiledFile(path: string): Promise<ProcessedFile | null> { |
| try { |
| const file = await this.vfs.readFile(this.projectId, path); |
| |
| if (file.type === 'html') { |
| return await this.processHTML(file, this.blobUrls); |
| } else if (file.type === 'css') { |
| return await this.processCSS(file, new Map()); |
| } else { |
| return { |
| path: file.path, |
| content: file.content as string, |
| mimeType: file.mimeType |
| }; |
| } |
| } catch { |
| return null; |
| } |
| } |
| } |
|
|