Files
rspade_system/app/RSpade/Integrations/Jqhtml/JqhtmlWebpackCompiler.php
root f6fac6c4bc Fix bin/publish: copy docs.dist from project root
Fix bin/publish: use correct .env path for rspade_system
Fix bin/publish script: prevent grep exit code 1 from terminating script

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-21 02:08:33 +00:00

320 lines
10 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 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];
}
}