Files
rspade_system/app/RSpade/Integrations/Jqhtml/JqhtmlWebpackCompiler.php
2025-10-21 05:25:33 +00:00

287 lines
9.3 KiB
PHP
Executable File

<?php
namespace App\RSpade\Integrations\Jqhtml;
use App\RSpade\Integrations\Jqhtml\Jqhtml_Exception_ViewException;
/**
* JqhtmlWebpackCompiler - Compiles JQHTML templates to JavaScript during bundle build
*
* This service handles the compilation of .jqhtml template files into JavaScript
* during the bundle build process. It uses the @jqhtml/parser NPM package to
* perform the compilation and caches the results based on file modification times.
*
* Features:
* - Uses @jqhtml/parser for template compilation
* - Caches compiled templates based on file mtime
* - Throws fatal errors on compilation failures (fail loud)
* - Integrates with the bundle compilation pipeline
*/
#[Instantiatable]
class JqhtmlWebpackCompiler
{
/**
* Path to the jqhtml-compile binary
*/
protected string $compiler_path;
/**
* Cache directory for compiled templates
*/
protected string $cache_dir;
/**
* Constructor
*/
public function __construct()
{
// Use official jqhtml CLI compiler from npm package
$this->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 file redirection instead of proc_open() to avoid pipe buffer truncation
// proc_open() has race conditions with feof() that cause silent data loss on large outputs (35KB+)
// Generate temp file for output
$temp_file = storage_path('rsx-tmp/jqhtml_compile_' . uniqid() . '.js');
// Redirect stdout to file, stderr to stdout for error capture, then echo exit code
$command = sprintf(
'%s compile %s --format iife --sourcemap > %s 2>&1; echo $?',
escapeshellarg($this->compiler_path),
escapeshellarg($file_path),
escapeshellarg($temp_file)
);
// Execute command synchronously - shell_exec captures the exit code from echo $?
$result = shell_exec($command);
$return_code = (int)trim($result);
// Read the compiled output from file
$compiled_js = '';
if (file_exists($temp_file)) {
$compiled_js = file_get_contents($temp_file);
unlink($temp_file); // Clean up temp file
}
// If there was an error, the output file will contain the error message
$output_str = $compiled_js;
// Check for compilation errors
if ($return_code !== 0) {
// Error output captured in output_str via 2>&1 redirection
// 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 - compiled_js already contains the output from the temp file
// 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];
}
}