compiler_path = base_path('node_modules/@jqhtml/parser/bin/jqhtml-compile'); $this->cache_dir = storage_path('rsx-tmp/jqhtml-cache'); // Ensure cache directory exists if (!is_dir($this->cache_dir)) { mkdir($this->cache_dir, 0755, true); } // Validate compiler exists - MUST exist if (!file_exists($this->compiler_path)) { throw new \RuntimeException( "Official JQHTML CLI compiler not found at: {$this->compiler_path}. " . "Run 'npm install @jqhtml/parser@^2.2.59' to install the official CLI compiler." ); } } /** * Compile a single JQHTML template file * * @param string $file_path Path to .jqhtml file * @return string Compiled JavaScript code * @throws \RuntimeException On compilation failure */ public function compile_file(string $file_path): string { if (!file_exists($file_path)) { throw new \RuntimeException("JQHTML template not found: {$file_path}"); } // Get file modification time for cache key $mtime = filemtime($file_path); $cache_key = md5($file_path) . '_' . $mtime; $cache_file = $this->cache_dir . '/' . $cache_key . '.js'; // Check if cached version exists if (file_exists($cache_file)) { console_debug("JQHTML", "Using cached JQHTML template: {$file_path}"); return file_get_contents($cache_file); } console_debug("JQHTML", "Compiling JQHTML template: {$file_path}"); // Execute official CLI compiler with IIFE format for self-registering templates // CRITICAL: Must include --sourcemap for proper error mapping in bundles // JQHTML v2.2.65+ uses Mozilla source-map library for reliable concatenation // IMPORTANT: Using proc_open() instead of \exec_safe() to handle large template outputs // \exec_safe() can truncate output for complex templates due to line-by-line buffering $command = sprintf( '%s compile %s --format iife --sourcemap', escapeshellarg($this->compiler_path), escapeshellarg($file_path) ); $descriptors = [ 0 => ['pipe', 'r'], // stdin 1 => ['pipe', 'w'], // stdout 2 => ['pipe', 'w'] // stderr ]; $process = proc_open($command, $descriptors, $pipes); if (!is_resource($process)) { throw new \RuntimeException("Failed to execute jqhtml compiler"); } // Close stdin fclose($pipes[0]); // Set blocking mode to ensure complete reads stream_set_blocking($pipes[1], true); stream_set_blocking($pipes[2], true); // Read stdout and stderr completely in chunks // CRITICAL: Use feof() as loop condition to prevent race condition truncation // Checking feof() AFTER empty reads can cause 8192-byte truncation bug $output_str = ''; $error_str = ''; // Read stdout until EOF while (!feof($pipes[1])) { $chunk = fread($pipes[1], 8192); if ($chunk !== false) { $output_str .= $chunk; } } // Read stderr until EOF while (!feof($pipes[2])) { $chunk = fread($pipes[2], 8192); if ($chunk !== false) { $error_str .= $chunk; } } fclose($pipes[1]); fclose($pipes[2]); // Get return code $return_code = proc_close($process); // Combine stdout and stderr for error messages if ($return_code !== 0 && !empty($error_str)) { $output_str = $error_str . "\n" . $output_str; } // Check for compilation errors if ($return_code !== 0) { // Official CLI outputs errors to stderr (captured in stdout with 2>&1) // Try multiple error formats // Format 1: "at filename:line:column" (newer format) if (preg_match('/at [^:]+:(\d+):(\d+)/i', $output_str, $matches)) { $line = (int)$matches[1]; $column = (int)$matches[2]; throw new Jqhtml_Exception_ViewException( "JQHTML compilation failed:\n{$output_str}", $file_path, $line, $column ); } // Format 2: "Error at line X, column Y:" (older format) if (preg_match('/Error at line (\d+), column (\d+):/i', $output_str, $matches)) { $line = (int)$matches[1]; $column = (int)$matches[2]; throw new Jqhtml_Exception_ViewException( "JQHTML compilation failed:\n{$output_str}", $file_path, $line, $column ); } // Format 3: No line number found - generic error throw new \RuntimeException( "JQHTML compilation failed for {$file_path}:\n{$output_str}" ); } // Success - the output is the compiled JavaScript $compiled_js = $output_str; // Don't add any comments - they break sourcemap line offsets // Just use the compiler output as-is $wrapped_js = $compiled_js; // Ensure proper newline at end if (!str_ends_with($wrapped_js, "\n")) { $wrapped_js .= "\n"; } // Cache the compiled result file_put_contents($cache_file, $wrapped_js); // Clean up old cache files for this template $this->cleanup_old_cache($file_path, $cache_key); return $wrapped_js; } /** * Compile multiple JQHTML template files * * @param array $files Array of file paths * @return array Compiled JavaScript code keyed by file path */ public function compile_files(array $files): array { $compiled = []; foreach ($files as $file) { try { $compiled[$file] = $this->compile_file($file); } catch (\Exception $e) { // FAIL LOUD - don't continue on error throw new \RuntimeException( "Failed to compile JQHTML templates: " . $e->getMessage() ); } } return $compiled; } /** * Extract component name from file path * * @param string $file_path Path to .jqhtml file * @return string Component name */ protected function extract_component_name(string $file_path): string { // Remove base path and extension $relative = str_replace(base_path() . '/', '', $file_path); $relative = preg_replace('/\.jqhtml$/i', '', $relative); // Convert path to component name (e.g., rsx/app/components/MyComponent) // to MyComponent or components/MyComponent $parts = explode('/', $relative); // Use the filename as the component name return basename($relative); } /** * Clean up old cache files for a template * * @param string $file_path Original template path * @param string $current_cache_key Current cache key to keep */ protected function cleanup_old_cache(string $file_path, string $current_cache_key): void { $file_hash = md5($file_path); $pattern = $this->cache_dir . '/' . $file_hash . '_*.js'; foreach (glob($pattern) as $cache_file) { $cache_key = basename($cache_file, '.js'); if ($cache_key !== $current_cache_key) { unlink($cache_file); } } } /** * Clear all cached templates */ public function clear_cache(): void { $pattern = $this->cache_dir . '/*.js'; foreach (glob($pattern) as $cache_file) { unlink($cache_file); } console_debug("JQHTML", "Cleared JQHTML template cache"); } /** * Get cache statistics * * @return array Cache statistics */ public function get_cache_stats(): array { $pattern = $this->cache_dir . '/*.js'; $files = glob($pattern); $total_size = 0; foreach ($files as $file) { $total_size += filesize($file); } return [ 'cache_dir' => $this->cache_dir, 'cached_files' => count($files), 'total_size' => $total_size, 'total_size_human' => $this->format_bytes($total_size) ]; } /** * Format bytes to human readable * * @param int $bytes * @return string */ protected function format_bytes(int $bytes): string { $units = ['B', 'KB', 'MB', 'GB']; $i = 0; while ($bytes >= 1024 && $i < count($units) - 1) { $bytes /= 1024; $i++; } return round($bytes, 2) . ' ' . $units[$i]; } }