🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
524 lines
16 KiB
PHP
Executable File
524 lines
16 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
|
|
{
|
|
/**
|
|
* RPC server script path
|
|
*/
|
|
protected const RPC_SERVER_SCRIPT = 'app/RSpade/Integrations/Jqhtml/resource/jqhtml-compile-server.js';
|
|
|
|
/**
|
|
* RPC server socket path
|
|
*/
|
|
protected const RPC_SOCKET = 'storage/rsx-tmp/jqhtml-compile-server.sock';
|
|
|
|
/**
|
|
* RPC server process
|
|
*/
|
|
protected static $rpc_server_process = null;
|
|
|
|
/**
|
|
* RPC request ID counter
|
|
*/
|
|
protected static $request_id = 0;
|
|
|
|
/**
|
|
* Path to jqhtml-compile binary for package validation (RPC server used for actual compilation)
|
|
*/
|
|
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}");
|
|
|
|
// Compile via RPC server
|
|
$compiled_js = static::_compile_via_rpc($file_path);
|
|
|
|
// Extract template variable name and append registration call
|
|
// CRITICAL: Do NOT add extra newlines - they break sourcemap line offsets
|
|
// The registration is appended AFTER the sourcemap comment so it doesn't affect mappings
|
|
if (preg_match('/var\s+(template_\w+)\s*=/', $compiled_js, $matches)) {
|
|
$template_var = $matches[1];
|
|
// Append registration on same line as end of sourcemap (no extra newlines)
|
|
$compiled_js = rtrim($compiled_js) . "\njqhtml.register_template({$template_var});";
|
|
}
|
|
|
|
$wrapped_js = $compiled_js;
|
|
|
|
// Ensure exactly one newline at end (no extra)
|
|
$wrapped_js = rtrim($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];
|
|
}
|
|
|
|
/**
|
|
* Start RPC server for JQHTML compilation
|
|
* Lazy initialization - called automatically on first compile
|
|
*/
|
|
public static function start_rpc_server(): void
|
|
{
|
|
$socket_path = base_path(self::RPC_SOCKET);
|
|
$server_script = base_path(self::RPC_SERVER_SCRIPT);
|
|
|
|
if (!file_exists($server_script)) {
|
|
throw new \RuntimeException("JQHTML Compile RPC server script not found at {$server_script}");
|
|
}
|
|
|
|
// If socket exists, check if it's stale
|
|
if (file_exists($socket_path)) {
|
|
// Try to ping the existing server
|
|
if (static::ping_rpc_server()) {
|
|
// Server already running and responsive
|
|
return;
|
|
}
|
|
|
|
// Socket exists but server not responding - force cleanup
|
|
static::stop_rpc_server(force: true);
|
|
}
|
|
|
|
// Ensure socket directory exists
|
|
$socket_dir = dirname($socket_path);
|
|
if (!is_dir($socket_dir)) {
|
|
mkdir($socket_dir, 0755, true);
|
|
}
|
|
|
|
// Start RPC server
|
|
$process = new \Symfony\Component\Process\Process([
|
|
'node',
|
|
$server_script,
|
|
'--socket=' . $socket_path
|
|
]);
|
|
|
|
$process->setWorkingDirectory(base_path());
|
|
$process->setTimeout(null); // No timeout for long-running server
|
|
$process->start();
|
|
|
|
static::$rpc_server_process = $process;
|
|
|
|
// Wait for server to be ready (ping until it responds)
|
|
$max_wait_ms = 10000; // 10 seconds
|
|
$wait_interval_ms = 50;
|
|
$iterations = $max_wait_ms / $wait_interval_ms;
|
|
|
|
for ($i = 0; $i < $iterations; $i++) {
|
|
usleep($wait_interval_ms * 1000); // Convert to microseconds
|
|
|
|
if (static::ping_rpc_server()) {
|
|
// Register shutdown handler to clean up server
|
|
register_shutdown_function([self::class, 'stop_rpc_server']);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Timeout waiting for server
|
|
throw new \RuntimeException(
|
|
"JQHTML Compile RPC server failed to start within {$max_wait_ms}ms.\n" .
|
|
"Check that Node.js and @jqhtml/parser are installed."
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Ping the RPC server to check if it's alive
|
|
*
|
|
* @return bool True if server responds, false otherwise
|
|
*/
|
|
public static function ping_rpc_server(): bool
|
|
{
|
|
$socket_path = base_path(self::RPC_SOCKET);
|
|
|
|
if (!file_exists($socket_path)) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
$socket = @stream_socket_client('unix://' . $socket_path, $errno, $errstr, 1);
|
|
if (!$socket) {
|
|
return false;
|
|
}
|
|
|
|
// Set blocking mode for reliable reads/writes
|
|
stream_set_blocking($socket, true);
|
|
|
|
static::$request_id++;
|
|
$request = json_encode([
|
|
'id' => static::$request_id,
|
|
'method' => 'ping'
|
|
]) . "\n";
|
|
|
|
fwrite($socket, $request);
|
|
|
|
$response = fgets($socket);
|
|
fclose($socket);
|
|
|
|
if (!$response) {
|
|
return false;
|
|
}
|
|
|
|
$result = json_decode($response, true);
|
|
return isset($result['result']) && $result['result'] === 'pong';
|
|
} catch (\Exception $e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop the RPC server
|
|
*
|
|
* @param bool $force If true, forcefully kill the server (used for cleanup)
|
|
*/
|
|
public static function stop_rpc_server(bool $force = false): void
|
|
{
|
|
$socket_path = base_path(self::RPC_SOCKET);
|
|
|
|
if ($force) {
|
|
// Force cleanup: send shutdown, wait briefly, then SIGTERM if needed
|
|
if (file_exists($socket_path)) {
|
|
try {
|
|
$socket = @stream_socket_client('unix://' . $socket_path, $errno, $errstr, 1);
|
|
if ($socket) {
|
|
// Set blocking mode for reliable writes
|
|
stream_set_blocking($socket, true);
|
|
|
|
static::$request_id++;
|
|
$request = json_encode([
|
|
'id' => static::$request_id,
|
|
'method' => 'shutdown'
|
|
]) . "\n";
|
|
|
|
fwrite($socket, $request);
|
|
fclose($socket);
|
|
}
|
|
} catch (\Exception $e) {
|
|
// Ignore errors during force shutdown
|
|
}
|
|
|
|
// Wait briefly for graceful shutdown
|
|
usleep(100000); // 100ms
|
|
|
|
// Force remove socket
|
|
@unlink($socket_path);
|
|
}
|
|
|
|
// Kill process if still running
|
|
if (static::$rpc_server_process && static::$rpc_server_process->isRunning()) {
|
|
static::$rpc_server_process->stop(0, SIGTERM);
|
|
}
|
|
} else {
|
|
// Graceful shutdown: just send shutdown command
|
|
if (file_exists($socket_path)) {
|
|
try {
|
|
$socket = @stream_socket_client('unix://' . $socket_path, $errno, $errstr, 1);
|
|
if ($socket) {
|
|
// Set blocking mode for reliable writes
|
|
stream_set_blocking($socket, true);
|
|
|
|
static::$request_id++;
|
|
$request = json_encode([
|
|
'id' => static::$request_id,
|
|
'method' => 'shutdown'
|
|
]) . "\n";
|
|
|
|
fwrite($socket, $request);
|
|
fclose($socket);
|
|
}
|
|
} catch (\Exception $e) {
|
|
// Ignore errors during graceful shutdown
|
|
}
|
|
}
|
|
}
|
|
|
|
static::$rpc_server_process = null;
|
|
}
|
|
|
|
/**
|
|
* Compile file via RPC server
|
|
*
|
|
* @param string $file_path Path to file to compile
|
|
* @return string Compiled code
|
|
*/
|
|
protected static function _compile_via_rpc(string $file_path): string
|
|
{
|
|
// Start RPC server on first use (lazy initialization)
|
|
if (static::$rpc_server_process === null) {
|
|
static::start_rpc_server();
|
|
}
|
|
|
|
$socket_path = base_path(self::RPC_SOCKET);
|
|
|
|
if (!file_exists($socket_path)) {
|
|
throw new \RuntimeException("JQHTML Compile RPC server socket not found at {$socket_path}");
|
|
}
|
|
|
|
// Connect to RPC server
|
|
$socket = @stream_socket_client('unix://' . $socket_path, $errno, $errstr, 5);
|
|
if (!$socket) {
|
|
throw new \RuntimeException("Failed to connect to JQHTML Compile RPC server: {$errstr}");
|
|
}
|
|
|
|
// Set blocking mode for reliable reads/writes
|
|
stream_set_blocking($socket, true);
|
|
|
|
// Send compile request
|
|
static::$request_id++;
|
|
$request = json_encode([
|
|
'id' => static::$request_id,
|
|
'method' => 'compile',
|
|
'files' => [
|
|
[
|
|
'path' => $file_path,
|
|
'format' => 'iife',
|
|
'sourcemap' => true
|
|
]
|
|
]
|
|
]) . "\n";
|
|
|
|
fwrite($socket, $request);
|
|
|
|
// Read response
|
|
$response = fgets($socket);
|
|
fclose($socket);
|
|
|
|
if (!$response) {
|
|
throw new \RuntimeException("JQHTML Compile RPC server returned empty response for {$file_path}");
|
|
}
|
|
|
|
$result = json_decode($response, true);
|
|
|
|
if (!$result || !isset($result['results'])) {
|
|
throw new \RuntimeException(
|
|
"JQHTML Compile RPC server returned invalid response for {$file_path}:\n" . $response
|
|
);
|
|
}
|
|
|
|
$file_result = $result['results'][$file_path] ?? null;
|
|
|
|
if (!$file_result) {
|
|
throw new \RuntimeException("JQHTML Compile RPC server did not return result for {$file_path}");
|
|
}
|
|
|
|
// Handle error response
|
|
if ($file_result['status'] === 'error' && isset($file_result['error'])) {
|
|
$error = $file_result['error'];
|
|
$message = $error['message'] ?? 'Unknown error';
|
|
$line = $error['line'] ?? null;
|
|
$column = $error['column'] ?? null;
|
|
|
|
// Throw appropriate exception type
|
|
if ($line && $column) {
|
|
throw new Jqhtml_Exception_ViewException(
|
|
"JQHTML compilation failed:\n{$message}",
|
|
$file_path,
|
|
$line,
|
|
$column
|
|
);
|
|
}
|
|
|
|
throw new \RuntimeException(
|
|
"JQHTML compilation failed for {$file_path}:\n{$message}"
|
|
);
|
|
}
|
|
|
|
// Success - return compiled code
|
|
if ($file_result['status'] === 'success' && isset($file_result['result'])) {
|
|
return $file_result['result'];
|
|
}
|
|
|
|
// Unknown response format
|
|
throw new \RuntimeException(
|
|
"JQHTML Compile RPC server returned unexpected response for {$file_path}:\n" .
|
|
json_encode($file_result, JSON_PRETTY_PRINT)
|
|
);
|
|
}
|
|
} |