584 lines
20 KiB
PHP
584 lines
20 KiB
PHP
<?php
|
|
|
|
namespace FluentCrm\App;
|
|
|
|
use Exception;
|
|
use FluentCrm\Framework\Support\Arr;
|
|
|
|
class Vite
|
|
{
|
|
private array $moduleScripts = [];
|
|
private string $viteHostProtocol = 'http://';
|
|
private string $viteHost = 'localhost';
|
|
private string $vitePort = '5174';
|
|
private string $resourceDirectory = 'resources/';
|
|
|
|
/**
|
|
* Bridge from Mix-era enqueue paths to Vite source paths.
|
|
*
|
|
* The PHP codebase still calls fluentCrmMix('admin/js/app.js') and
|
|
* fluentCrmMix('admin/css/app3.css') even though Vite's manifest and
|
|
* dev server are keyed by source paths (admin/app.js, styles/app3.scss).
|
|
* Both mapToSourcePath() and getSourcePathForManifest() read this
|
|
* table — one source of truth.
|
|
*/
|
|
private const MIX_TO_VITE_PATH_MAP = [
|
|
// JS: Mix wrote `admin/js/<name>.js`, Vite reads the source path
|
|
'admin/js/boot.js' => 'admin/boot.js',
|
|
'admin/js/app.js' => 'admin/app.js',
|
|
'admin/js/adminbar-search.js' => 'admin/adminbar-search.js',
|
|
'admin/js/global_admin.js' => 'admin/global_admin.js',
|
|
'admin/js/setup-wizard.js' => 'admin/setup-wizard.js',
|
|
'admin/js/contact-navigations.js' => 'admin/experiments/contact-navigations.js',
|
|
'admin/js/visual-editor.js' => 'admin/visual-editor/visual-editor.js',
|
|
'public/public_pref.js' => 'public/public_pref.js',
|
|
|
|
// CSS: Mix wrote `admin/css/<name>.css`, Vite serves SCSS sources
|
|
'admin/css/admin_rtl.css' => 'scss/admin_rtl.scss',
|
|
'admin/css/app_global.css' => 'scss/app_global.scss',
|
|
'admin/css/setup-wizard.css' => 'scss/setup-wizard.scss',
|
|
'admin/css/app3.css' => 'styles/app3.scss',
|
|
'public/public_pref.css' => 'scss/public_pref.scss',
|
|
];
|
|
|
|
protected static ?Vite $instance = null;
|
|
public ?string $lastJsHandle = null;
|
|
private ?array $manifestData = null;
|
|
private array $enqueuedChunkCss = [];
|
|
|
|
public function __construct()
|
|
{
|
|
$serverConfigPath = FLUENTCRM_PLUGIN_PATH . 'config' . DIRECTORY_SEPARATOR . 'vite.json';
|
|
if (file_exists($serverConfigPath)) {
|
|
$serverConfig = json_decode(file_get_contents($serverConfigPath));
|
|
$this->viteHost = $serverConfig->host ?: $this->viteHost;
|
|
$this->viteHostProtocol = $serverConfig->protocol ?: $this->viteHostProtocol;
|
|
$this->vitePort = $serverConfig->port ?: $this->vitePort;
|
|
}
|
|
|
|
// Add global filter to convert Vite scripts to modules
|
|
add_filter('script_loader_tag', [$this, 'maybeConvertToModule'], 999, 3);
|
|
}
|
|
|
|
/**
|
|
* Convert scripts from Vite dev server or built assets to ES modules
|
|
*/
|
|
public function maybeConvertToModule($tag, $handle, $src): string
|
|
{
|
|
// Fast rejection for scripts that obviously aren't ours. The filter
|
|
// is registered globally at priority 999 so it fires for EVERY
|
|
// script on EVERY admin page (including pages where FluentCRM
|
|
// isn't active). On a typical admin page that's ~30 unrelated
|
|
// scripts. Short-circuit here so the detailed checks below only
|
|
// run for our own assets.
|
|
$isPotentiallyOurs =
|
|
strpos($src, FLUENTCRM_PLUGIN_URL) !== false ||
|
|
strpos($src, 'localhost:' . $this->vitePort) !== false ||
|
|
strpos($src, '@vite/client') !== false ||
|
|
in_array($handle, $this->moduleScripts, true);
|
|
|
|
if (!$isPotentiallyOurs) {
|
|
return $tag;
|
|
}
|
|
|
|
// Check if this script is from Vite dev server, Vite built assets, or is a module script
|
|
$isViteScript = false;
|
|
$fluentCrmAssetBase = FLUENTCRM_PLUGIN_URL . 'assets/';
|
|
|
|
// Check if from dev server
|
|
if (strpos($src, 'localhost:' . $this->vitePort) !== false || strpos($src, '@vite/client') !== false) {
|
|
$isViteScript = true;
|
|
}
|
|
|
|
// Check if explicitly marked as module script
|
|
if (in_array($handle, $this->moduleScripts)) {
|
|
$isViteScript = true;
|
|
}
|
|
|
|
// Check if from FluentCRM Vite built assets in production (or dev mode without server).
|
|
// Only rewrite this plugin's own built files, never third-party plugin assets.
|
|
if (!$this->shouldServeViaDevServer()) {
|
|
$assetPatterns = [
|
|
$fluentCrmAssetBase . 'admin/',
|
|
$fluentCrmAssetBase . 'public/',
|
|
];
|
|
foreach ($assetPatterns as $pattern) {
|
|
if (strpos($src, $pattern) !== false && strpos($src, '.js') !== false) {
|
|
// Exclude third-party libs that are not ES modules
|
|
$excludePatterns = [
|
|
'/libs/',
|
|
'/vendor/',
|
|
'purify.min.js',
|
|
];
|
|
$isExcluded = false;
|
|
foreach ($excludePatterns as $exclude) {
|
|
if (strpos($src, $exclude) !== false) {
|
|
$isExcluded = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!$isExcluded) {
|
|
$isViteScript = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($isViteScript) {
|
|
// Already has type="module"
|
|
if (strpos($tag, 'type="module"') !== false || strpos($tag, "type='module'") !== false) {
|
|
return $tag;
|
|
}
|
|
|
|
// Convert to module
|
|
$tag = preg_replace('/<script\s+/', '<script type="module" crossorigin ', $tag);
|
|
}
|
|
|
|
return $tag;
|
|
}
|
|
|
|
private static function getInstance(): Vite
|
|
{
|
|
if (static::$instance === null) {
|
|
static::$instance = new static();
|
|
// Load manifest whenever Vite dev server is NOT running.
|
|
// This covers both production (env != dev) AND dev installs where
|
|
// the Vite server isn't active, so built assets are served instead.
|
|
if (!static::$instance->usingDevMode() || !static::$instance->isViteServerRunning()) {
|
|
(static::$instance)->loadViteManifest();
|
|
}
|
|
}
|
|
|
|
return static::$instance;
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private function loadViteManifest()
|
|
{
|
|
if (!empty($this->manifestData)) {
|
|
return;
|
|
}
|
|
|
|
$manifestPath = FLUENTCRM_PLUGIN_PATH . 'config' . DIRECTORY_SEPARATOR . 'vite_config.php';
|
|
|
|
if (file_exists($manifestPath)) {
|
|
$this->manifestData = require $manifestPath;
|
|
}
|
|
|
|
if (empty($this->manifestData)) {
|
|
$this->manifestData = [];
|
|
// In production, you might want to uncomment this to enforce manifest requirement
|
|
// throw new Exception('Vite Manifest Not Found. Run: npm run dev or npm run build');
|
|
}
|
|
}
|
|
|
|
public static function enqueueScript($handle, $src, $dependency = [], $version = null, $inFooter = false): Vite
|
|
{
|
|
return static::getInstance()->enqueue_script(
|
|
$handle,
|
|
$src,
|
|
$dependency,
|
|
$version,
|
|
$inFooter
|
|
);
|
|
}
|
|
|
|
private function enqueue_script($handle, $src, $dependency = [], $version = null, $inFooter = false): Vite
|
|
{
|
|
if (in_array($handle, $this->moduleScripts)) {
|
|
if ($this->usingDevMode()) {
|
|
$callerReference = (debug_backtrace(2)[1]);
|
|
$fileName = explode('plugins', $callerReference['file']);
|
|
$line = $callerReference['line'];
|
|
// Uncomment to debug duplicate handles
|
|
// throw new \Exception("Handle already used: $handle at File: {$fileName[1]} Line: $line");
|
|
}
|
|
}
|
|
|
|
$this->moduleScripts[] = $handle;
|
|
$this->lastJsHandle = $handle;
|
|
|
|
// No per-handle script_loader_tag filter needed — the constructor
|
|
// registers maybeConvertToModule globally at priority 999 and it
|
|
// already detects handles in $moduleScripts as Vite scripts.
|
|
|
|
if ($this->shouldServeViaDevServer()) {
|
|
$srcPath = $this->getVitePath() . $src;
|
|
} else {
|
|
$assetFile = $this->getFileFromManifest($src);
|
|
$srcPath = $this->getProductionFilePath($assetFile);
|
|
}
|
|
|
|
if (empty($srcPath)) {
|
|
return $this;
|
|
}
|
|
|
|
$version = empty($version) ? FLUENTCRM_PLUGIN_VERSION : $version;
|
|
|
|
wp_enqueue_script(
|
|
$handle,
|
|
$srcPath,
|
|
$dependency,
|
|
$version,
|
|
$inFooter
|
|
);
|
|
|
|
return $this;
|
|
}
|
|
|
|
private function getFileFromManifest($src)
|
|
{
|
|
if (isset($this->manifestData[$this->resourceDirectory . $src])) {
|
|
return $this->manifestData[$this->resourceDirectory . $src];
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
private function getProductionFilePath($file): string
|
|
{
|
|
if (!isset($file['file'])) {
|
|
return '';
|
|
}
|
|
|
|
$assetPath = static::getAssetPath();
|
|
$this->ensureChunkCssIsLoaded($file);
|
|
|
|
return ($assetPath . $file['file']);
|
|
}
|
|
|
|
// Per-chunk CSS auto-enqueue. The Vite build's mergeCssChunksPlugin
|
|
// collapses most chunk CSS into admin/css/style.css (which AdminMenu.php
|
|
// enqueues explicitly), and the moveManifestPlugin then strips the
|
|
// merged paths from the manifest. What remains in manifest `css` arrays
|
|
// is only files that survived to disk (e.g. legacy SCSS entry outputs
|
|
// like admin/css/admin_rtl.css) — those we enqueue here.
|
|
private function ensureChunkCssIsLoaded($file)
|
|
{
|
|
$assetPath = static::getAssetPath();
|
|
$cssFiles = $this->collectChunkCssFiles($file);
|
|
|
|
foreach ($cssFiles as $cssPath) {
|
|
if (isset($this->enqueuedChunkCss[$cssPath])) {
|
|
continue;
|
|
}
|
|
|
|
wp_enqueue_style(
|
|
'fluentcrm_vite_css_' . md5($cssPath),
|
|
$assetPath . $cssPath,
|
|
[],
|
|
FLUENTCRM_PLUGIN_VERSION
|
|
);
|
|
|
|
$this->enqueuedChunkCss[$cssPath] = true;
|
|
}
|
|
}
|
|
|
|
private function collectChunkCssFiles($file, &$visited = []): array
|
|
{
|
|
$cssFiles = [];
|
|
|
|
if (!is_array($file)) {
|
|
return $cssFiles;
|
|
}
|
|
|
|
$fileId = isset($file['file']) ? $file['file'] : md5(wp_json_encode($file));
|
|
if (isset($visited[$fileId])) {
|
|
return $cssFiles;
|
|
}
|
|
$visited[$fileId] = true;
|
|
|
|
if (isset($file['css']) && is_array($file['css'])) {
|
|
foreach ($file['css'] as $path) {
|
|
if (is_string($path) && $path !== '') {
|
|
$cssFiles[] = $path;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isset($file['imports']) && is_array($file['imports'])) {
|
|
foreach ($file['imports'] as $importKey) {
|
|
if (!isset($this->manifestData[$importKey]) || !is_array($this->manifestData[$importKey])) {
|
|
continue;
|
|
}
|
|
|
|
$cssFiles = array_merge($cssFiles, $this->collectChunkCssFiles($this->manifestData[$importKey], $visited));
|
|
}
|
|
}
|
|
|
|
return array_values(array_unique($cssFiles));
|
|
}
|
|
|
|
public function with($params)
|
|
{
|
|
if (!is_array($params) || !Arr::isAssoc($params) || empty($this->lastJsHandle)) {
|
|
$this->lastJsHandle = null;
|
|
return;
|
|
}
|
|
|
|
foreach ($params as $key => $val) {
|
|
wp_localize_script($this->lastJsHandle, $key, $val);
|
|
}
|
|
$this->lastJsHandle = null;
|
|
}
|
|
|
|
public static function enqueueStyle($handle, $src, $dependency = [], $version = null, $media = 'all')
|
|
{
|
|
static::getInstance()->enqueue_style(
|
|
$handle,
|
|
$src,
|
|
$dependency,
|
|
$version,
|
|
$media
|
|
);
|
|
}
|
|
|
|
private function enqueue_style($handle, $src, $dependency = [], $version = null, $media = 'all')
|
|
{
|
|
if ($this->shouldServeViaDevServer()) {
|
|
$srcPath = $this->getVitePath() . $src;
|
|
} else {
|
|
$assetFile = $this->getFileFromManifest($src);
|
|
$srcPath = $this->getProductionFilePath($assetFile);
|
|
}
|
|
|
|
if (empty($srcPath)) {
|
|
return;
|
|
}
|
|
|
|
$version = empty($version) ? FLUENTCRM_PLUGIN_VERSION : $version;
|
|
|
|
wp_enqueue_style(
|
|
$handle,
|
|
$srcPath,
|
|
$dependency,
|
|
$version,
|
|
$media
|
|
);
|
|
}
|
|
|
|
public static function enqueueStaticScript($handle, $src, $dependency = [], $version = null, $inFooter = false): Vite
|
|
{
|
|
$version = empty($version) ? FLUENTCRM_PLUGIN_VERSION : $version;
|
|
|
|
return static::getInstance()->enqueue_static_script(
|
|
$handle,
|
|
$src,
|
|
$dependency,
|
|
$version,
|
|
$inFooter
|
|
);
|
|
}
|
|
|
|
private function enqueue_static_script($handle, $src, $dependency = [], $version = null, $inFooter = false): Vite
|
|
{
|
|
$version = empty($version) ? FLUENTCRM_PLUGIN_VERSION : $version;
|
|
|
|
wp_enqueue_script(
|
|
$handle,
|
|
$this->getStaticEnqueuePath($src),
|
|
$dependency,
|
|
$version,
|
|
$inFooter
|
|
);
|
|
|
|
return $this;
|
|
}
|
|
|
|
private function getStaticEnqueuePath($path): string
|
|
{
|
|
if ($this->shouldServeViaDevServer()) {
|
|
return $this->getVitePath() . $path;
|
|
}
|
|
|
|
return $this->get_asset_url($path);
|
|
}
|
|
|
|
public static function enqueueStaticStyle($handle, $src, $dependency = [], $version = null, $media = 'all')
|
|
{
|
|
$version = empty($version) ? FLUENTCRM_PLUGIN_VERSION : $version;
|
|
|
|
static::getInstance()->enqueue_static_style(
|
|
$handle, $src, $dependency, $version, $media
|
|
);
|
|
}
|
|
|
|
private function enqueue_static_style($handle, $src, $dependency = [], $version = null, $media = 'all')
|
|
{
|
|
$version = empty($version) ? FLUENTCRM_PLUGIN_VERSION : $version;
|
|
|
|
wp_enqueue_style(
|
|
$handle,
|
|
$this->getStaticEnqueuePath($src),
|
|
$dependency,
|
|
$version,
|
|
$media
|
|
);
|
|
}
|
|
|
|
public static function underDevelopment(): bool
|
|
{
|
|
return static::getInstance()->usingDevMode();
|
|
}
|
|
|
|
public function usingDevMode(): bool
|
|
{
|
|
$app = FluentCrm();
|
|
return $app['config']->get('app.env') === 'dev';
|
|
}
|
|
|
|
/**
|
|
* True only when env=dev AND the Vite dev server is reachable.
|
|
* Use this — not usingDevMode() — to decide whether to proxy URLs to
|
|
* the Vite server. When the server is down we fall back to built assets.
|
|
*/
|
|
private function shouldServeViaDevServer(): bool
|
|
{
|
|
return $this->usingDevMode() && $this->isViteServerRunning();
|
|
}
|
|
|
|
/**
|
|
* Check if Vite dev server is actually running
|
|
*/
|
|
private function isViteServerRunning(): bool
|
|
{
|
|
static $isRunning = null;
|
|
|
|
if ($isRunning !== null) {
|
|
return $isRunning;
|
|
}
|
|
|
|
// Check if Vite client endpoint is accessible
|
|
$viteUrl = $this->viteHostProtocol . $this->viteHost . ':' . $this->vitePort . '/@vite/client';
|
|
|
|
$response = wp_remote_get($viteUrl, [
|
|
'timeout' => 1,
|
|
'sslverify' => false
|
|
]);
|
|
|
|
$isRunning = !is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200;
|
|
|
|
return $isRunning;
|
|
}
|
|
|
|
public function getVitePath(): string
|
|
{
|
|
$protocol = rtrim($this->viteHostProtocol, ':/');
|
|
$host = rtrim($this->viteHost, '/');
|
|
$port = $this->vitePort;
|
|
$resource = ltrim($this->resourceDirectory, '/');
|
|
|
|
return sprintf('%s://%s:%s/%s', $protocol, $host, $port, $resource);
|
|
}
|
|
|
|
public static function getEnqueuePath($path = ''): string
|
|
{
|
|
$vite = static::getInstance();
|
|
|
|
// Normalize the path - remove leading slash
|
|
$path = ltrim($path, '/');
|
|
|
|
if (!$vite->usingDevMode()) {
|
|
// In production, map the path to source path first for manifest lookup
|
|
$sourcePath = $vite->getSourcePathForManifest($path);
|
|
$assetFile = $vite->getFileFromManifest($sourcePath);
|
|
if ($assetFile) {
|
|
$srcPath = $vite->getProductionFilePath($assetFile);
|
|
} else {
|
|
// Fallback to direct asset path
|
|
$srcPath = static::getAssetPath() . $path;
|
|
}
|
|
} else {
|
|
// Check if Vite dev server is actually running
|
|
if ($vite->isViteServerRunning()) {
|
|
// Use Vite dev server URL (source path, served via HMR)
|
|
$srcPath = $vite->mapToSourcePath($path);
|
|
} else {
|
|
// Vite server not running — resolve via manifest just like production.
|
|
// The plugin ships with env=dev, so without this the fallback would use
|
|
// the old Mix path pattern (assets/admin/js/app.js) which doesn't
|
|
// exist in the Vite output (assets/admin/app.js). 404 = blank app.
|
|
$sourcePath = $vite->getSourcePathForManifest($path);
|
|
$assetFile = $vite->getFileFromManifest($sourcePath);
|
|
if ($assetFile) {
|
|
$srcPath = $vite->getProductionFilePath($assetFile);
|
|
} else {
|
|
// Last resort: direct path (for assets not in manifest)
|
|
$srcPath = static::getAssetPath() . $path;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $srcPath;
|
|
}
|
|
|
|
/**
|
|
* Resolve a Mix-style enqueue path to a dev-server URL.
|
|
* Used when the Vite dev server is running.
|
|
*/
|
|
private function mapToSourcePath($path): string
|
|
{
|
|
if (isset(self::MIX_TO_VITE_PATH_MAP[$path])) {
|
|
return $this->getVitePath() . self::MIX_TO_VITE_PATH_MAP[$path];
|
|
}
|
|
|
|
// If path already starts with resources/, use it as-is
|
|
if (strpos($path, 'resources/') === 0) {
|
|
return $this->getVitePath() . substr($path, 10); // Remove 'resources/' prefix
|
|
}
|
|
|
|
// Fallback: use the path as-is (getVitePath includes resources/)
|
|
return $this->getVitePath() . $path;
|
|
}
|
|
|
|
/**
|
|
* Resolve a Mix-style enqueue path to the manifest source key.
|
|
* Used in production for manifest lookups.
|
|
*/
|
|
private function getSourcePathForManifest($path): string
|
|
{
|
|
return self::MIX_TO_VITE_PATH_MAP[$path] ?? $path;
|
|
}
|
|
|
|
public static function getAssetUrl($path = ''): string
|
|
{
|
|
return esc_url(static::getInstance()->get_asset_url($path) ?? '');
|
|
}
|
|
|
|
private function get_asset_url($path = ''): string
|
|
{
|
|
if ($this->shouldServeViaDevServer()) {
|
|
return $this->getVitePath() . $path;
|
|
}
|
|
|
|
return FLUENTCRM_PLUGIN_URL . 'assets/' . ltrim($path, '/');
|
|
}
|
|
|
|
static function getAssetPath(): string
|
|
{
|
|
return FLUENTCRM_PLUGIN_URL . 'assets/';
|
|
}
|
|
|
|
/**
|
|
* Inject Vite client for HMR in development mode
|
|
*/
|
|
public static function injectViteClient()
|
|
{
|
|
$vite = static::getInstance();
|
|
|
|
if ($vite->shouldServeViaDevServer()) {
|
|
$protocol = rtrim($vite->viteHostProtocol, ':/');
|
|
$host = rtrim($vite->viteHost, '/');
|
|
$port = $vite->vitePort;
|
|
|
|
// Vite client URL should NOT include /resources/
|
|
$viteClientUrl = sprintf('%s://%s:%s/@vite/client', $protocol, $host, $port);
|
|
echo '<script type="module" crossorigin src="' . esc_url($viteClientUrl) . '"></script>' . "\n";
|
|
}
|
|
}
|
|
|
|
}
|