diff --git a/.gitattributes b/.gitattributes index 0c3b228c5..78bf811f1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,3 +8,4 @@ .gitattributes export-ignore .gitpod.yml export-ignore CHANGELOG.md export-ignore +/package.json export-ignore diff --git a/modules/cms/twig/Extension.php b/modules/cms/twig/Extension.php index c08bcddeb..c96630c46 100644 --- a/modules/cms/twig/Extension.php +++ b/modules/cms/twig/Extension.php @@ -1,11 +1,12 @@ ['html']]), + new TwigSimpleFunction('vite', [$this, 'viteFunction'], $options), ]; } @@ -166,6 +168,14 @@ public function themeFilter($url): string return $this->controller->themeUrl($url); } + /** + * Generates Vite tags via Laravel's Vite Object. + */ + public function viteFunction(array $entrypoints, string $package): \Illuminate\Support\HtmlString + { + return Vite::tags($entrypoints, $package); + } + /** * Opens a layout block. */ diff --git a/modules/cms/twig/ScriptsNode.php b/modules/cms/twig/ScriptsNode.php index 4b2b9cdf5..264061354 100644 --- a/modules/cms/twig/ScriptsNode.php +++ b/modules/cms/twig/ScriptsNode.php @@ -26,6 +26,7 @@ public function compile(TwigCompiler $compiler) $compiler ->addDebugInfo($this) ->write("echo \$this->env->getExtension('Cms\Twig\Extension')->assetsFunction('js');\n") + ->write("echo \$this->env->getExtension('Cms\Twig\Extension')->assetsFunction('vite');\n") ->write("echo \$this->env->getExtension('Cms\Twig\Extension')->displayBlock('scripts');\n") ; } diff --git a/modules/system/.eslintignore b/modules/system/.eslintignore index b6b27f1df..8cdcdeed5 100644 --- a/modules/system/.eslintignore +++ b/modules/system/.eslintignore @@ -12,3 +12,5 @@ assets/vendor # Ignore test fixtures tests/js +tests/fixtures/themes/test/assets/js +tests/fixtures/themes/vitetest/assets/javascript diff --git a/modules/system/ServiceProvider.php b/modules/system/ServiceProvider.php index 0996fbbb2..0d2ce8562 100644 --- a/modules/system/ServiceProvider.php +++ b/modules/system/ServiceProvider.php @@ -8,6 +8,7 @@ use Config; use DateInterval; use Event; +use Illuminate\Foundation\Vite; use Illuminate\Pagination\Paginator; use Illuminate\Support\Facades\Schema; use Markdown; @@ -142,6 +143,9 @@ protected function registerSingletons() $this->app->singleton('backend.auth', function () { return \Backend\Classes\AuthManager::instance(); }); + + // Register the Laravel Vite singleton + $this->app->singleton(Vite::class, \System\Classes\Vite::class); } /** @@ -316,12 +320,20 @@ protected function registerConsole() $this->registerConsoleCommand('plugin.rollback', \System\Console\PluginRollback::class); $this->registerConsoleCommand('plugin.list', \System\Console\PluginList::class); - $this->registerConsoleCommand('mix.install', \System\Console\MixInstall::class); - $this->registerConsoleCommand('mix.update', \System\Console\MixUpdate::class); - $this->registerConsoleCommand('mix.list', \System\Console\MixList::class); - $this->registerConsoleCommand('mix.compile', \System\Console\MixCompile::class); - $this->registerConsoleCommand('mix.watch', \System\Console\MixWatch::class); - $this->registerConsoleCommand('mix.run', \System\Console\MixRun::class); + $this->registerConsoleCommand('mix.compile', Console\Asset\Mix\MixCompile::class); + $this->registerConsoleCommand('mix.config', Console\Asset\Mix\MixConfig::class); + $this->registerConsoleCommand('mix.install', Console\Asset\Mix\MixInstall::class); + $this->registerConsoleCommand('mix.list', Console\Asset\Mix\MixList::class); + $this->registerConsoleCommand('mix.watch', Console\Asset\Mix\MixWatch::class); + + $this->registerConsoleCommand('vite.compile', Console\Asset\Vite\ViteCompile::class); + $this->registerConsoleCommand('vite.config', Console\Asset\Vite\ViteConfig::class); + $this->registerConsoleCommand('vite.install', Console\Asset\Vite\ViteInstall::class); + $this->registerConsoleCommand('vite.list', Console\Asset\Vite\ViteList::class); + $this->registerConsoleCommand('vite.watch', Console\Asset\Vite\ViteWatch::class); + + $this->registerConsoleCommand('npm.run', Console\NpmRun::class); + $this->registerConsoleCommand('npm.update', Console\NpmUpdate::class); } /* diff --git a/modules/system/classes/CompilableAssets.php b/modules/system/classes/CompilableAssets.php new file mode 100644 index 000000000..38453bbee --- /dev/null +++ b/modules/system/classes/CompilableAssets.php @@ -0,0 +1,350 @@ + + * @copyright Winter CMS Maintainers + */ +class CompilableAssets +{ + use \Winter\Storm\Support\Traits\Singleton; + + /** + * The filename that stores the package definition. + */ + protected string $packageJson = 'package.json'; + + /** + * @var array> List of package types and registration methods + */ + protected array $compilableConfigs = [ + 'mix' => [ + 'configFile' => 'winter.mix.js' + ], + 'vite' => [ + 'configFile' => 'vite.config.mjs' + ] + ]; + + /** + * A list of packages registered for compiling. + */ + protected array $packages = []; + + /** + * Registered callbacks. + */ + protected static array $callbacks = []; + + /** + * Constructor. + */ + public function init(): void + { + $packagePaths = []; + + /* + * Get packages registered in plugins. + * + * In the Plugin.php file for your plugin, you can define the `registerMixPackages` or `registerVitePackages` + * method and return an array, with the name of the package being the key, and the build config path - relative + * to the plugin directory - as the value. + * + * Example: + * + * public function registerMixPackages(): array + * { + * return [ + * 'package-name-1' => 'winter.mix.js', + * 'package-name-2' => 'assets/js/build.js', + * ]; + * } + * + * public function registerVitePackages(): array + * { + * return [ + * 'package-name-1' => 'vite.config.mjs', + * 'package-name-2' => 'assets/js/build.js', + * ]; + * } + */ + foreach ($this->compilableConfigs as $type => $config) { + $packages = PluginManager::instance()->getRegistrationMethodValues( + $this->getRegistrationMethod($type) + ); + if (count($packages)) { + foreach ($packages as $pluginCode => $packageArray) { + if (!is_array($packageArray)) { + continue; + } + + foreach ($packageArray as $name => $package) { + $this->registerPackage( + $name, + PluginManager::instance()->getPluginPath($pluginCode) . '/' . $package, + $type + ); + } + } + } + + // Get the currently enabled modules + $enabledModules = Config::get('cms.loadModules', []); + + if (in_array('Cms', $enabledModules)) { + // Allow current theme to define mix assets + $theme = Theme::getActiveTheme(); + + if (!is_null($theme)) { + $mix = $theme->getConfigValue($type, []); + + if (count($mix)) { + foreach ($mix as $name => $file) { + $this->registerPackage($name, $theme->getPath() . '/' . $file, $type); + } + } + } + } + + // Search modules for compilable packages to autoregister + foreach ($enabledModules as $module) { + $module = strtolower($module); + $path = base_path('modules' . DIRECTORY_SEPARATOR . $module) . DIRECTORY_SEPARATOR . $config['configFile']; + if (File::exists($path)) { + $packagePaths[$type]["module-$module"] = $path; + } + } + + // Search plugins for compilable packages to autoregister + $plugins = PluginManager::instance()->getPlugins(); + foreach ($plugins as $plugin) { + $path = $plugin->getPluginPath() . '/' . $config['configFile']; + if (File::exists($path)) { + $packagePaths[$type][$plugin->getPluginIdentifier()] = $path; + } + } + + // Search themes for compilable packages to autoregister + if (in_array('Cms', $enabledModules)) { + $themes = Theme::all(); + foreach ($themes as $theme) { + $path = $theme->getPath() . '/' . $config['configFile']; + if (File::exists($path)) { + $packagePaths[$type]["theme-" . $theme->getId()] = $path; + } + } + } + } + + // Register the autodiscovered compilable packages + foreach ($packagePaths as $type => $packages) { + foreach ($packages as $package => $path) { + try { + $this->registerPackage($package, $path, $type); + } catch (SystemException $e) { + // Either the package name or the config file path have already been registered, skip. + continue; + } + } + } + } + + /** + * Register a compilable config. + */ + public function registerCompilable(string $name, array $config): void + { + $this->compilableConfigs[$name] = $config; + } + + /** + * Registers a callback for processing. + */ + public static function registerCallback(callable $callback): void + { + static::$callbacks[] = $callback; + } + + /** + * Calls the deferred callbacks. + */ + public function fireCallbacks(): void + { + // Call callbacks + foreach (static::$callbacks as $callback) { + $callback($this); + } + } + + /** + * Returns the count of packages registered. + */ + public function getPackageCount(): int + { + return array_sum(array_map(fn ($packages) => count($packages), ...$this->packages)); + } + + /** + * Returns all packages registered. + */ + public function getPackages(string $type, bool $includeIgnored = false): array + { + $packages = $this->packages[$type] ?? []; + + ksort($packages); + + if (!$includeIgnored) { + return array_filter($packages, function ($package) { + return !($package['ignored'] ?? false); + }); + } + + return $packages; + } + + /** + * Returns if package(s) is registered. + */ + public function hasPackage(string $name, bool $includeIgnored = false): bool + { + foreach ($this->packages ?? [] as $packages) { + foreach ($packages as $packageName => $config) { + if (($name === $packageName) && (!$config['ignored'] || $includeIgnored)) { + return true; + } + } + } + + return false; + } + + /** + * Returns package(s). + */ + public function getPackage(string $name, bool $includeIgnored = false): array + { + $results = []; + foreach ($this->packages ?? [] as $packages) { + foreach ($packages as $packageName => $config) { + if (($name === $packageName) && (!$config['ignored'] || $includeIgnored)) { + $results[] = $config; + } + } + } + + return $results; + } + + /** + * Registers an entity as a package for compilation. + * + * Entities can include plugins, components, themes, modules and much more. + * + * The name of the package is an alias that can be used to reference this package in other methods within this + * class. + * + * By default, the `CompilableAssets` class will look for a `package.json` file for Node dependencies, and a config + * file for the compilable configuration + * + * @param string $name The name of the package being registered + * @param string $path The path to the compilable JS configuration file. If there is a related package.json file + * then it is required to be present in the same directory as the config file + * @param string $type The type of compilable + * @throws SystemException + */ + public function registerPackage(string $name, string $path, string $type = 'mix'): void + { + // Symbolize the path + $path = File::symbolizePath($path); + + // Normalize the arguments + $name = strtolower($name); + $resolvedPath = PathResolver::resolve($path); + $pinfo = pathinfo($resolvedPath); + $path = Str::after($pinfo['dirname'], base_path() . DIRECTORY_SEPARATOR); + $configFile = $pinfo['basename']; + + // Require $configFile to be a JS file + $extension = File::extension($configFile); + if (!in_array($extension, ['js', 'mjs'])) { + throw new SystemException(sprintf( + 'Compilable configuration for package "%s" must be a JavaScript file ending with .js or .mjs', + $name + )); + } + + // Check that the package path exists + if (!File::exists($path)) { + throw new SystemException(sprintf( + 'Cannot register "%s" as a compilable package; the "%s" path does not exist.', + $name, + $path + )); + } + + // Check for any existing packages already registered under the provided name + if (isset($this->packages[$name])) { + throw new SystemException(sprintf( + 'Cannot register "%s" as a compilable package; it has already been registered at %s.', + $name, + $this->packages[$name]['config'] + )); + } + + $package = "$path/{$this->packageJson}"; + $config = $path . DIRECTORY_SEPARATOR . $configFile; + + // Check for any existing package that already registers the given compilable config path + foreach ($this->packages[$type] ?? [] as $packageName => $settings) { + if ($settings['config'] === $config) { + throw new SystemException(sprintf( + 'Cannot register "%s" (%s) as a compilable package; it has already been registered as %s.', + $name, + $config, + $packageName + )); + } + } + + // Register the package + $this->packages[$type][$name] = [ + 'path' => $path, + 'package' => $package, + 'config' => $config, + 'ignored' => $this->isPackageIgnored($path), + ]; + } + + /** + * Returns the registration method for a compiler type + */ + protected function getRegistrationMethod(string $type): string + { + return sprintf('register%sPackages', ucfirst($type)); + } + + /** + * Check if the provided package is ignored. + */ + protected function isPackageIgnored(string $packagePath): bool + { + // Load the main package.json for the project + $packageJson = new PackageJson(base_path($this->packageJson)); + return $packageJson->hasIgnoredPackage($packagePath); + } +} diff --git a/modules/system/classes/MixAssets.php b/modules/system/classes/MixAssets.php deleted file mode 100644 index 0a6c41493..000000000 --- a/modules/system/classes/MixAssets.php +++ /dev/null @@ -1,268 +0,0 @@ -, Jack Wilkinson - * @author Winter CMS - */ -class MixAssets -{ - use \Winter\Storm\Support\Traits\Singleton; - - /** - * The filename that stores the package definition. - */ - protected string $packageJson = 'package.json'; - - /** - * The filename that stores the Laravel Mix configuration - */ - protected string $mixJs = 'winter.mix.js'; - - /** - * A list of packages registered for mixing. - */ - protected array $packages = []; - - /** - * Registered callbacks. - */ - protected static array $callbacks = []; - - /** - * Constructor. - */ - public function init(): void - { - /* - * Get packages registered in plugins. - * - * In the Plugin.php file for your plugin, you can define the "registerMixPackages" method and return an array, - * with the name of the package being the key, and the build config path - relative to the plugin directory - as - * the value. - * - * Example: - * - * public function registerMixPackages() - * { - * return [ - * 'package-name-1' => 'winter.mix.js', - * 'package-name-2' => 'assets/js/build.js', - * ]; - * } - */ - $packages = PluginManager::instance()->getRegistrationMethodValues('registerMixPackages'); - if (count($packages)) { - foreach ($packages as $pluginCode => $packageArray) { - if (!is_array($packageArray)) { - continue; - } - - foreach ($packageArray as $name => $package) { - $this->registerPackage($name, PluginManager::instance()->getPluginPath($pluginCode) . '/' . $package); - } - } - } - - // Get the currently enabled modules - $enabledModules = Config::get('cms.loadModules', []); - - if (in_array('Cms', $enabledModules)) { - // Allow current theme to define mix assets - $theme = Theme::getActiveTheme(); - - if (!is_null($theme)) { - $mix = $theme->getConfigValue('mix', []); - - if (count($mix)) { - foreach ($mix as $name => $file) { - $this->registerPackage($name, $theme->getPath() . '/' . $file); - } - } - } - } - - $packagePaths = []; - - // Search modules for Mix packages to autoregister - foreach ($enabledModules as $module) { - $module = strtolower($module); - $path = base_path('modules' . DIRECTORY_SEPARATOR . $module) . DIRECTORY_SEPARATOR . $this->mixJs; - if (File::exists($path)) { - $packagePaths["module-$module"] = $path; - } - } - - // Search plugins for Mix packages to autoregister - $plugins = PluginManager::instance()->getPlugins(); - foreach ($plugins as $plugin) { - $path = $plugin->getPluginPath() . '/' . $this->mixJs; - if (File::exists($path)) { - $packagePaths[$plugin->getPluginIdentifier()] = $path; - } - } - - // Search themes for Mix packages to autoregister - if (in_array('Cms', $enabledModules)) { - $themes = Theme::all(); - foreach ($themes as $theme) { - $path = $theme->getPath() . '/' . $this->mixJs; - if (File::exists($path)) { - $packagePaths["theme-" . $theme->getId()] = $path; - } - } - } - - // Register the autodiscovered Mix packages - foreach ($packagePaths as $package => $path) { - try { - $this->registerPackage($package, $path); - } catch (SystemException $e) { - // Either the package name or the mixJs path have already been registered, skip. - continue; - } - } - } - - /** - * Registers a callback for processing. - */ - public static function registerCallback(callable $callback): void - { - static::$callbacks[] = $callback; - } - - /** - * Calls the deferred callbacks. - */ - public function fireCallbacks(): void - { - // Call callbacks - foreach (static::$callbacks as $callback) { - $callback($this); - } - } - - /** - * Returns the count of packages registered. - */ - public function getPackageCount(): int - { - return count($this->packages); - } - - /** - * Returns all packages registered. - */ - public function getPackages(bool $includeIgnored = false): array - { - ksort($this->packages); - - if (!$includeIgnored) { - return array_filter($this->packages, function ($package) { - return !($package['ignored'] ?? false); - }); - } - - return $this->packages; - } - - /** - * Registers an entity as a package for mixing. - * - * Entities can include plugins, components, themes, modules and much more. - * - * The name of the package is an alias that can be used to reference this package in other methods within this - * class. - * - * By default, the MixAssets class will look for a `package.json` file for Node dependencies, and a `winter.mix.js` - * file for the Laravel Mix configuration - * - * @param string $name The name of the package being registered - * @param string $path The path to the Mix JS configuration file. If there is a related package.json file then it is - * required to be present in the same directory as the winter.mix.js file - */ - public function registerPackage(string $name, string $path): void - { - // Symbolize the path - $path = File::symbolizePath($path); - - // Normalize the arguments - $name = strtolower($name); - $resolvedPath = PathResolver::resolve($path); - $pinfo = pathinfo($resolvedPath); - $path = Str::after($pinfo['dirname'], base_path() . DIRECTORY_SEPARATOR); - $mixJs = $pinfo['basename']; - - // Require $mixJs to be a JS file - $extension = File::extension($mixJs); - if ($extension !== 'js') { - throw new SystemException( - sprintf('The mix configuration for package "%s" must be a JavaScript file ending with .js', $name) - ); - } - - // Check that the package path exists - if (!File::exists($path)) { - throw new SystemException( - sprintf('Cannot register "%s" as a Mix package; the "%s" path does not exist.', $name, $path) - ); - } - - // Check for any existing packages already registered under the provided name - if (isset($this->packages[$name])) { - throw new SystemException( - sprintf('Cannot register "%s" as a Mix package; it has already been registered at %s.', $name, $this->packages[$name]['mix']) - ); - } - - $package = "$path/{$this->packageJson}"; - $mix = $path . DIRECTORY_SEPARATOR . $mixJs; - - // Check for any existing package that already registers the given Mix path - foreach ($this->packages as $packageName => $config) { - if ($config['mix'] === $mix) { - throw new SystemException( - sprintf('Cannot register "%s" (%s) as a Mix package; it has already been registered as %s.', $name, $mix, $packageName) - ); - } - } - - // Register the package - $this->packages[$name] = [ - 'path' => $path, - 'package' => $package, - 'mix' => $mix, - 'ignored' => $this->isPackageIgnored($path), - ]; - } - - /** - * Check if the provided package is ignored. - */ - protected function isPackageIgnored(string $packagePath): bool - { - // Load the main package.json for the project - $packageJsonPath = base_path($this->packageJson); - $packageJson = []; - if (File::exists($packageJsonPath)) { - $packageJson = json_decode(File::get($packageJsonPath), true); - } - $included = $packageJson['workspaces']['packages'] ?? []; - $ignored = $packageJson['workspaces']['ignoredPackages'] ?? []; - return in_array($packagePath, $ignored); - } -} diff --git a/modules/system/classes/PackageJson.php b/modules/system/classes/PackageJson.php new file mode 100644 index 000000000..379627cc2 --- /dev/null +++ b/modules/system/classes/PackageJson.php @@ -0,0 +1,263 @@ + + * @author Winter CMS + */ +class PackageJson +{ + /** + * The contents of the package.json being modified + */ + protected array $data; + + /** + * Create a new instance with optional path, loads file if file already exists + */ + public function __construct( + protected ?string $path = null + ) { + $this->data = File::exists($this->path) + ? json_decode(File::get($this->path), JSON_OBJECT_AS_ARRAY) + : []; + } + + /** + * Returns the package name if set + */ + public function getName(): ?string + { + return $this->data['name'] ?? null; + } + + /** + * Sets the package name, throws `InvalidArgumentException` on invalid name + */ + public function setName(?string $name): static + { + if (is_null($name)) { + unset($this->data['name']); + return $this; + } + + if ($name !== strtolower($name)) { + throw new InvalidArgumentException('Package names must be lower case'); + } + + if (preg_match('/^([._])/', $name)) { + throw new InvalidArgumentException('Package names must not start with . or _'); + } + + if (preg_match('/[~\'\"!()*]/', $name)) { + throw new InvalidArgumentException('Package names must not include special characters'); + } + + if (strlen($name) > 214) { + throw new InvalidArgumentException('Package names must not be longer than 214 characters'); + } + + if ($name !== trim($name)) { + throw new InvalidArgumentException('Package names must not include whitespace'); + } + + $this->data['name'] = $name; + + return $this; + } + + /** + * Checks if workspace package is set + */ + public function hasWorkspace(string $path): bool + { + return in_array($path, $this->data['workspaces']['packages'] ?? []); + } + + /** + * Adds a new workspace, removes from ignored workspaces if present + */ + public function addWorkspace(string $path): static + { + if (!in_array($path, $this->data['workspaces']['packages'] ?? [])) { + $this->data['workspaces']['packages'][] = $path; + } + + if (($key = array_search($path, $this->data['workspaces']['ignoredPackages'] ?? [])) !== false) { + // remove the package from ignored workspaces + unset($this->data['workspaces']['ignoredPackages'][$key]); + // reset keys + $this->data['workspaces']['ignoredPackages'] = array_values($this->data['workspaces']['ignoredPackages']); + } + + // Sort the packages + asort($this->data['workspaces']['packages']); + $this->data['workspaces']['packages'] = array_values($this->data['workspaces']['packages']); + + return $this; + } + + /** + * Removes a workspace + */ + public function removeWorkspace(string $path): static + { + if (($key = array_search($path, $this->data['workspaces']['packages'] ?? [])) !== false) { + // remove the package from workspace packages + unset($this->data['workspaces']['packages'][$key]); + // reset keys + $this->data['workspaces']['packages'] = array_values($this->data['workspaces']['packages']); + } + + return $this; + } + + /** + * Check if package is ignored + */ + public function hasIgnoredPackage(string $path): bool + { + return in_array($path, $this->data['workspaces']['ignoredPackages'] ?? []); + } + + /** + * Adds an ignored package, removes from workspaces if present + */ + public function addIgnoredPackage(string $path): static + { + if (!in_array($path, $this->data['workspaces']['ignoredPackages'] ?? [])) { + $this->data['workspaces']['ignoredPackages'][] = $path; + } + + if (($key = array_search($path, $this->data['workspaces']['packages'] ?? [])) !== false) { + // remove the package from ignored workspaces + unset($this->data['workspaces']['packages'][$key]); + // reset keys + $this->data['workspaces']['packages'] = array_values($this->data['workspaces']['packages']); + } + + // Sort the packages + asort($this->data['workspaces']['ignoredPackages']); + $this->data['workspaces']['ignoredPackages'] = array_values($this->data['workspaces']['ignoredPackages'] ?? []); + + return $this; + } + + /** + * Removes an ignored package + */ + public function removeIgnoredPackage(string $path): static + { + if (($key = array_search($path, $this->data['workspaces']['ignoredPackages'] ?? [])) !== false) { + // remove the package from workspace packages + unset($this->data['workspaces']['ignoredPackages'][$key]); + // reset keys + $this->data['workspaces']['ignoredPackages'] = array_values($this->data['workspaces']['ignoredPackages']); + } + + return $this; + } + + /** + * Checks if package.json has a dependency + */ + public function hasDependency(string $package): bool + { + return isset($this->data['dependencies'][$package]) || isset($this->data['devDependencies'][$package]); + } + + /** + * Adds a dependency, supports adding to `dependencies` or `devDependencies` based on `$dev` and allows moving if + * `$overwrite` is set + */ + public function addDependency(string $package, string $version, bool $dev = false, bool $overwrite = false): static + { + // If the dep is defined already, but we are not overwriting, then exit + if ( + (isset($this->data['dependencies'][$package]) || isset($this->data['devDependencies'][$package])) + && !$overwrite + ) { + return $this; + } + + // Clear any existing settings because we are overwriting + $this->removeDependency($package); + + // Define the dep + $this->data[$dev ? 'devDependencies' : 'dependencies'][$package] = $version; + + return $this; + } + + /** + * Removes a package from both `dependencies` and `devDependencies` + */ + public function removeDependency(string $package): static + { + unset($this->data['dependencies'][$package], $this->data['devDependencies'][$package]); + return $this; + } + + /** + * Returns if a script exists + */ + public function hasScript(string $name): bool + { + return isset($this->data['scripts'][$name]); + } + + /** + * Returns the value of a script by name + */ + public function getScript(string $name): ?string + { + return $this->data['scripts'][$name] ?? null; + } + + /** + * Adds a script + */ + public function addScript(string $name, string $script): static + { + $this->data['scripts'][$name] = $script; + return $this; + } + + /** + * Removes a script by name + */ + public function removeScript(string $name): static + { + unset($this->data['scripts'][$name]); + return $this; + } + + /** + * Returns the package.json contents as an array + */ + public function getContents(): array + { + return $this->data; + } + + /** + * Saves the contents to a file, if the object was init'ed with a path it will save to the path, or can be + * overwritten with `$path`. + */ + public function save(?string $path = null): int + { + return File::put( + $path ?? $this->path ?? throw new RuntimeException('Unable to save, no path given'), + json_encode($this->data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) + ); + } +} diff --git a/modules/system/classes/Vite.php b/modules/system/classes/Vite.php new file mode 100644 index 000000000..cad8848d0 --- /dev/null +++ b/modules/system/classes/Vite.php @@ -0,0 +1,50 @@ +getPackages('vite')[$package] ?? null)) { + throw new SystemException('Unable to resolve package: ' . $package); + } + + $this->useHotFile(base_path($compilableAssetPackage['path'] . '/public/hot')); + return parent::__invoke($entrypoints, $compilableAssetPackage['path'] . '/public/build'); + } + + /** + * Helper method to generate Vite tags for an entrypoint(s). + * + * @param string|array $entrypoints The list of entry points for Vite + * @param string $package The package name of the plugin or theme + * + * @throws SystemException + */ + public static function tags(array|string $entrypoints, string $package): HtmlString + { + return App::make(\Illuminate\Foundation\Vite::class)($entrypoints, $package); + } +} diff --git a/modules/system/console/MixCompile.php b/modules/system/console/MixCompile.php deleted file mode 100644 index 73cd8260c..000000000 --- a/modules/system/console/MixCompile.php +++ /dev/null @@ -1,248 +0,0 @@ -error('The Node dependencies are not available, try running mix:install first.'); - return 1; - } - - $mixedAssets = MixAssets::instance(); - $mixedAssets->fireCallbacks(); - - $registeredPackages = $mixedAssets->getPackages(); - $requestedPackages = $this->option('package') ?: []; - - // Calling commands in unit tests can cause the option casting to not work correctly, - // ensure that the option value is always an array - if (is_string($requestedPackages)) { - $requestedPackages = [$requestedPackages]; - } - - // Normalize the requestedPackages option - if (count($requestedPackages)) { - foreach ($requestedPackages as &$name) { - $name = strtolower($name); - } - unset($name); - } - - // Filter the registered packages to only include requested packages - if (count($requestedPackages) && count($registeredPackages)) { - // Get an updated list of packages including any newly added packages - $registeredPackages = $mixedAssets->getPackages(); - - // Filter the registered packages to only deal with the requested packages - foreach (array_keys($registeredPackages) as $name) { - if (!in_array($name, $requestedPackages)) { - unset($registeredPackages[$name]); - } - } - } - - if (!count($registeredPackages)) { - if (count($requestedPackages)) { - $this->error('No registered packages matched the requested packages for compilation.'); - return 1; - } else { - $this->info('No packages registered for mixing.'); - return 0; - } - } - - $exits = []; - foreach ($registeredPackages as $name => $package) { - $relativeMixJsPath = $package['mix']; - if (!$this->canCompilePackage($relativeMixJsPath)) { - $this->error(sprintf( - 'Unable to compile "%s", %s was not found in the package.json\'s workspaces.packages property.' - . ' Try running mix:install first.', - $name, - $relativeMixJsPath - )); - continue; - } - - if (!$this->option('silent')) { - $this->info(sprintf('Mixing package "%s"', $name)); - } - - $exitCode = $this->mixPackage(base_path($relativeMixJsPath)); - - if ($exitCode > 0) { - $this->error(sprintf('Unable to compile package "%s"', $name)); - } - - if ($this->option('stop-on-error') && $exitCode > 0) { - return $exitCode; - } - - $exits[] = $exitCode; - } - - return (int) !empty(array_filter($exits)); - } - - /** - * Get the package path for the provided winter.mix.js file - */ - protected function getPackagePath(string $mixJsPath): string - { - return pathinfo($mixJsPath, PATHINFO_DIRNAME); - } - - /** - * Get the path to the mix.webpack.js file for the provided winter.mix.js file - */ - protected function getWebpackJsPath(string $mixJsPath): string - { - return $this->getPackagePath($mixJsPath) . DIRECTORY_SEPARATOR . 'mix.webpack.js'; - } - - /** - * Check if Mix is able to compile the provided winter.mix.js file - */ - protected function canCompilePackage(string $mixJsPath): bool - { - if (!isset($this->packageJson)) { - // Load the main package.json for the project - $this->packageJson = $this->readNpmPackageManifest(); - } - - $workspacesPackages = $this->packageJson['workspaces']['packages'] ?? []; - - return in_array( - Str::replace(DIRECTORY_SEPARATOR, '/', $this->getPackagePath($mixJsPath)), - $workspacesPackages - ); - } - - /** - * Read the package.json file for the project, path configurable with the - * `--manifest` option - */ - protected function readNpmPackageManifest(): array - { - $packageJsonPath = base_path($this->option('manifest') ?? 'package.json'); - return File::exists($packageJsonPath) - ? json_decode(File::get($packageJsonPath), true) - : []; - } - - /** - * Run the mix command against the provided package - */ - protected function mixPackage(string $mixJsPath): int - { - $this->createWebpackConfig($mixJsPath); - $command = $this->createCommand($mixJsPath); - - $process = new Process( - $command, - $this->getPackagePath($mixJsPath), - ['NODE_ENV' => $this->option('production', false) ? 'production' : 'development'], - null, - null - ); - - try { - $process->setTty(true); - } catch (\Throwable $e) { - // This will fail on unsupported systems - } - - $exitCode = $process->run(function ($status, $stdout) { - if (!$this->option('silent')) { - $this->getOutput()->write($stdout); - } - }); - - $this->removeWebpackConfig($mixJsPath); - - return $exitCode; - } - - /** - * Create the command array to create a Process object with - */ - protected function createCommand(string $mixJsPath): array - { - $basePath = base_path(); - $command = $this->argument('webpackArgs') ?? []; - array_unshift( - $command, - $basePath . sprintf('%1$snode_modules%1$s.bin%1$swebpack', DIRECTORY_SEPARATOR), - 'build', - $this->option('silent') ? '--stats=none' : '--progress', - '--config=' . $this->getWebpackJsPath($mixJsPath) - ); - return $command; - } - - /** - * Create the temporary mix.webpack.js config file to run webpack with - */ - protected function createWebpackConfig(string $mixJsPath): void - { - $basePath = base_path(); - $fixture = File::get(__DIR__ . '/fixtures/mix.webpack.js.fixture'); - - $config = str_replace( - ['%base%', '%notificationInject%', '%mixConfigPath%', '%pluginsPath%', '%appPath%', '%silent%', '%noProgress%'], - [addslashes($basePath), 'mix._api.disableNotifications();', addslashes($mixJsPath), addslashes(plugins_path()), addslashes(base_path()), (int) $this->option('silent'), (int) $this->option('no-progress')], - $fixture - ); - - File::put($this->getWebpackJsPath($mixJsPath), $config); - } - - /** - * Remove the temporary mix.webpack.js file - */ - protected function removeWebpackConfig(string $mixJsPath): void - { - $webpackJsPath = $this->getWebpackJsPath($mixJsPath); - if (File::exists($webpackJsPath)) { - File::delete($webpackJsPath); - } - } -} diff --git a/modules/system/console/MixWatch.php b/modules/system/console/MixWatch.php deleted file mode 100644 index ebfaff888..000000000 --- a/modules/system/console/MixWatch.php +++ /dev/null @@ -1,113 +0,0 @@ -fireCallbacks(); - - $packages = $mixedAssets->getPackages(); - $name = $this->argument('package'); - - if (!in_array($name, array_keys($packages))) { - $this->error( - sprintf('Package "%s" is not a registered package.', $name) - ); - return 1; - } - - $package = $packages[$name]; - - $relativeMixJsPath = $package['mix']; - if (!$this->canCompilePackage($relativeMixJsPath)) { - $this->error( - sprintf('Unable to watch "%s", %s was not found in the package.json\'s workspaces.packages property. Try running mix:install first.', $name, $relativeMixJsPath) - ); - return 1; - } - - $this->info( - sprintf('Watching package "%s" for changes', $name) - ); - $this->mixJsPath = $relativeMixJsPath; - - if ($this->mixPackage(base_path($relativeMixJsPath)) !== 0) { - $this->error( - sprintf('Unable to compile package "%s"', $name) - ); - return 1; - } - - return 0; - } - - /** - * Create the command array to create a Process object with - */ - protected function createCommand(string $mixJsPath): array - { - $command = parent::createCommand($mixJsPath); - - // @TODO: Detect Homestead running on Windows to switch to watch-poll-options instead, see https://laravel-mix.com/docs/6.0/cli#polling - $command[] = '--watch'; - - return $command; - } - - /** - * Create the temporary mix.webpack.js config file to run webpack with - */ - protected function createWebpackConfig(string $mixJsPath): void - { - $basePath = base_path(); - $fixture = File::get(__DIR__ . '/fixtures/mix.webpack.js.fixture'); - - $config = str_replace( - ['%base%', '%notificationInject%', '%mixConfigPath%', '%pluginsPath%', '%appPath%', '%silent%', '%noProgress%'], - [addslashes($basePath), 'mix._api.disableNotifications();', addslashes($mixJsPath), addslashes(plugins_path()), addslashes(base_path()), (int) $this->option('silent'), (int) $this->option('no-progress')], - $fixture - ); - - File::put($this->getWebpackJsPath($mixJsPath), $config); - } - - /** - * Handle the cleanup of this command if a termination signal is received - */ - public function handleCleanup(): void - { - $this->newLine(); - $this->info('Cleaning up: ' . $this->getWebpackJsPath(base_path($this->mixJsPath))); - $this->removeWebpackConfig(base_path($this->mixJsPath)); - } -} diff --git a/modules/system/console/MixRun.php b/modules/system/console/NpmRun.php similarity index 69% rename from modules/system/console/MixRun.php rename to modules/system/console/NpmRun.php index 424cea57e..2306d8e8c 100644 --- a/modules/system/console/MixRun.php +++ b/modules/system/console/NpmRun.php @@ -1,21 +1,23 @@ -fireCallbacks(); + $compilableAssets = CompilableAssets::instance(); + $compilableAssets->fireCallbacks(); - $packages = $mixedAssets->getPackages(); $name = $this->argument('package'); $script = $this->argument('script'); - if (!in_array($name, array_keys($packages))) { + if (!$compilableAssets->hasPackage($name, true)) { $this->error( sprintf('Package "%s" is not a registered package.', $name) ); return 1; } - $package = $packages[$name]; - $packageJson = $this->readPackageJson($package); + $package = $compilableAssets->getPackage($name, true)[0] ?? []; + + // Assume that packages with matching names have matching package.json files + $packageJson = new PackageJson($package['package'] ?? null); - if (!isset($packageJson['scripts'][$script])) { + if (!$packageJson->hasScript($script)) { $this->error( sprintf('Script "%s" is not defined in package "%s".', $script, $name) ); @@ -80,23 +90,10 @@ public function handle(): int // This will fail on unsupported systems } - $exitCode = $process->run(function ($status, $stdout) { + return $process->run(function ($status, $stdout) { if (!$this->option('silent')) { $this->getOutput()->write($stdout); } }); - - return $exitCode; - } - - /** - * Reads the package.json file for the given package. - */ - protected function readPackageJson(array $package): array - { - $packageJsonPath = base_path($package['package']); - return File::exists($packageJsonPath) - ? json_decode(File::get($packageJsonPath), true) - : []; } } diff --git a/modules/system/console/MixUpdate.php b/modules/system/console/NpmUpdate.php similarity index 61% rename from modules/system/console/MixUpdate.php rename to modules/system/console/NpmUpdate.php index d87a261fb..5fbb17a46 100644 --- a/modules/system/console/MixUpdate.php +++ b/modules/system/console/NpmUpdate.php @@ -1,11 +1,15 @@ - 'update', 'completed' => 'updated', ]; @@ -31,5 +35,12 @@ class MixUpdate extends MixInstall /** * @inheritDoc */ - protected $npmCommand = 'update'; + protected string $npmCommand = 'update'; + + /** + * @inheritDoc + */ + public $replaces = [ + 'mix:update' + ]; } diff --git a/modules/system/console/asset/AssetCompile.php b/modules/system/console/asset/AssetCompile.php new file mode 100644 index 000000000..6710f6ad9 --- /dev/null +++ b/modules/system/console/asset/AssetCompile.php @@ -0,0 +1,256 @@ +error(sprintf( + 'The Node dependencies are not available, try running %s:install first.', + $type + )); + return 1; + } + + $compilableAssets = CompilableAssets::instance(); + $compilableAssets->fireCallbacks(); + + $registeredPackages = $compilableAssets->getPackages($type); + $requestedPackages = $this->option('package') ?: []; + + // Calling commands in unit tests can cause the option casting to not work correctly, + // ensure that the option value is always an array + if (is_string($requestedPackages)) { + $requestedPackages = [$requestedPackages]; + } + + // Normalize the requestedPackages option + if (count($requestedPackages)) { + foreach ($requestedPackages as &$name) { + $name = strtolower($name); + } + unset($name); + } + + // Filter the registered packages to only include requested packages + if (count($requestedPackages) && count($registeredPackages)) { + // Get an updated list of packages including any newly added packages + $registeredPackages = $compilableAssets->getPackages($type); + + // Filter the registered packages to only deal with the requested packages + foreach (array_keys($registeredPackages) as $name) { + if (!in_array($name, $requestedPackages)) { + unset($registeredPackages[$name]); + } + } + } + + if (!count($registeredPackages)) { + if (count($requestedPackages)) { + $this->error('No registered packages matched the requested packages for compilation.'); + return 1; + } else { + $this->info('No packages registered for mixing.'); + return 0; + } + } + + $exits = []; + foreach ($registeredPackages as $name => $package) { + $relativeMixJsPath = $package['config']; + if (!$this->isPackageWithinWorkspace($relativeMixJsPath)) { + $this->error(sprintf( + 'Unable to compile "%s", %s was not found in the package.json\'s workspaces.packages property.' + . ' Try running %s:install first.', + $name, + $relativeMixJsPath, + $type + )); + continue; + } + + if (!$this->option('silent')) { + $this->info(sprintf('Compiling package "%s"', $name)); + } + + $exitCode = $this->executeProcess(base_path($relativeMixJsPath)); + + if ($exitCode > 0) { + $this->error(sprintf('Unable to compile package "%s"', $name)); + } + + if ($this->option('stop-on-error') && $exitCode > 0) { + return $exitCode; + } + + $exits[] = $exitCode; + } + + return (int) !empty(array_filter($exits)); + } + + public function watchHandle(string $type): int + { + $compilableAssets = CompilableAssets::instance(); + $compilableAssets->fireCallbacks(); + + $packages = $compilableAssets->getPackages($type); + $name = $this->argument('package'); + + if (!in_array($name, array_keys($packages))) { + $this->error( + sprintf('Package "%s" is not a registered package.', $name) + ); + return 1; + } + + $package = $packages[$name]; + + $relativeConfigPath = $package['config']; + if (!$this->isPackageWithinWorkspace($relativeConfigPath)) { + $this->error(sprintf( + 'Unable to watch "%s", %s was not found in the package.json\'s workspaces.packages property. Try running %s:install first.', + $name, + $relativeConfigPath, + $type + )); + return 1; + } + + $this->info(sprintf('Watching package "%s" for changes', $name)); + $this->watchingFilePath = $relativeConfigPath; + + if ($this->executeProcess(base_path($relativeConfigPath)) !== 0) { + $this->error(sprintf('Unable to compile package "%s"', $name)); + return 1; + } + + return 0; + } + + /** + * Get the package path for the provided winter.mix.js file + */ + protected function getPackagePath(string $path): string + { + return pathinfo($path, PATHINFO_DIRNAME); + } + + /** + * Get the path to the mix.webpack.js file for the provided winter.mix.js file + */ + protected function getJsConfigPath(string $path): string + { + return $this->getPackagePath($path) . DIRECTORY_SEPARATOR . $this->configFile; + } + + /** + * Check if Mix is able to compile the provided winter.mix.js file + */ + protected function isPackageWithinWorkspace(string $mixJsPath): bool + { + if (!isset($this->packageJson)) { + // Load the main package.json for the project + $this->packageJson = $this->getNpmPackageManifest(); + } + + return $this->packageJson->hasWorkspace( + Str::replace(DIRECTORY_SEPARATOR, '/', $this->getPackagePath($mixJsPath)) + ); + } + + /** + * Read the package.json file for the project, path configurable with the + * `--manifest` option + */ + protected function getNpmPackageManifest(): PackageJson + { + return new PackageJson(base_path($this->option('manifest') ?? 'package.json')); + } + + /** + * Run the mix command against the provided package + */ + protected function executeProcess(string $configPath): int + { + $this->beforeExecution($configPath); + $command = $this->createCommand($configPath); + + $process = new Process( + $command, + $this->getPackagePath($configPath), + ['NODE_ENV' => $this->option('production', false) ? 'production' : 'development'], + null, + null + ); + + if (!$this->option('disable-tty')) { + try { + $process->setTty(true); + } catch (\Throwable $e) { + // This will fail on unsupported systems + } + } + + $exitCode = $process->run(function ($status, $stdout) { + if (!$this->option('silent')) { + $this->getOutput()->write($stdout); + } + }); + + $this->afterExecution($configPath); + + return $exitCode; + } + + /** + * Ran before dispatching the compile process, use for setting up + */ + protected function beforeExecution(string $configPath): void + { + // do nothing + } + + /** + * Ran after dispatching the compile process, use for tearing down + */ + protected function afterExecution(string $configPath): void + { + // do nothing + } + + /** + * Create the command array to create a Process object with + */ + abstract protected function createCommand(string $configPath): array; +} diff --git a/modules/system/console/asset/AssetConfig.php b/modules/system/console/asset/AssetConfig.php new file mode 100644 index 000000000..df12b6d19 --- /dev/null +++ b/modules/system/console/asset/AssetConfig.php @@ -0,0 +1,202 @@ + [ + 'tailwindcss' => '^3.4.0', + '@tailwindcss/forms' => '^0.5.2', + '@tailwindcss/typography' => '^0.5.2', + ], + 'vue' => [ + 'vue' => '^3.4.0', + ] + ]; + + /** + * Local cache of fixture path + */ + private string $fixturePath; + + /** + * The type of compilable to configure + */ + protected string $assetType; + + /** + * The name of the config file + */ + protected string $configFile; + + /** + * Execute the console command. + */ + public function handle(): int + { + $package = $this->argument('packageName'); + + $this->fixturePath = __DIR__ . '/fixtures/config'; + + $compilableAssets = CompilableAssets::instance(); + $compilableAssets->fireCallbacks(); + + $packages = $compilableAssets->getPackages($this->assetType, true); + + if ( + isset($packages[$package]) + && !$this->confirm('Package `' . $package . '` has already been configured, are you sure you wish to continue?') + ) { + return 1; + } + + [$path, $type] = $this->getPackagePathType($package); + + if (is_null($path) || is_null($type)) { + $this->error('Package `' . $package . '` could not be resolved'); + return 1; + } + + $packageJson = new PackageJson($path . '/package.json'); + if (!$packageJson->getName()) { + $packageJson->setName(strtolower(str_replace('.', '-', $package))); + } + + $this->installConfigs($packageJson, $package, $type, $path, [ + 'tailwind' => $this->option('tailwind'), + 'vue' => $this->option('vue'), + 'stubs' => $this->option('stubs') + ]); + + $packageJson->save(); + + return 0; + } + + /** + * Resolve the path and type of the package by name + * + * @return array|null[] + */ + protected function getPackagePathType(string $package): array + { + if (str_starts_with($package, 'theme-')) { + if ($theme = Theme::load(str_after($package, 'theme-'))) { + return [$theme->getPath(), static::TYPE_THEME]; + } + + return [null, null]; + } + + if ($plugin = PluginManager::instance()->findByIdentifier($package)) { + return [$plugin->getPluginPath(), static::TYPE_PLUGIN]; + } + + return [null, null]; + } + + /** + * Write out config files based on assetType and the requested options + */ + protected function installConfigs( + PackageJson $packageJson, + string $package, + string $type, + string $path, + array $options + ): void { + if ($options['tailwind']) { + $this->writeFile( + $path . '/tailwind.config.js', + File::get($this->fixturePath . '/tailwind/tailwind.' . $type . '.config.js.fixture') + ); + + $this->writeFile( + $path . '/postcss.config.mjs', + File::get($this->fixturePath . '/tailwind/postcss.config.js.fixture') + ); + + foreach ($this->packages['tailwind'] as $dependency => $version) { + $packageJson->addDependency($dependency, $version, dev: true); + } + } + + if ($options['vue']) { + foreach ($this->packages['vue'] as $dependency => $version) { + $packageJson->addDependency($dependency, $version, dev: true); + } + } + + $packageName = strtolower(str_replace('.', '-', $package)); + + if ($options['stubs']) { + // Setup css stubs + if (!File::exists($path . '/assets/src/css')) { + File::makeDirectory($path . '/assets/src/css', recursive: true); + } + + $this->writeFile( + $path . '/assets/src/css/' . $packageName . '.css', + File::get($this->fixturePath . '/css/' . ($options['tailwind'] ? 'tailwind' : 'default') . '.css.fixture') + ); + + // Setup js stubs + if (!File::exists($path . '/assets/src/js')) { + File::makeDirectory($path . '/assets/src/js', recursive: true); + } + + $this->writeFile( + $path . '/assets/src/js/' . $packageName . '.js', + File::get($this->fixturePath . '/js/' . ($options['vue'] ? 'vue' : 'default') . '.js.fixture') + ); + } + + $config = str_replace( + '{{packageName}}', + $packageName, + File::get( + sprintf( + '%s/%s/%s%s%s.js.fixture', + $this->fixturePath, + $this->assetType, + pathinfo($this->configFile, PATHINFO_FILENAME), + $options['tailwind'] ? '.tailwind' : '', + $options['vue'] ? '.vue' : '', + ) + ) + ); + + $this->writeFile($path . '/' . $this->configFile, $config); + } + + /** + * Write a file but ask for conformation before overwriting + */ + protected function writeFile(string $path, string $content): int + { + if (File::exists($path) && !$this->confirm(sprintf('%s already exists, overwrite?', basename($path)))) { + return 0; + } + + return File::put($path, $content); + } +} diff --git a/modules/system/console/MixInstall.php b/modules/system/console/asset/AssetInstall.php similarity index 60% rename from modules/system/console/MixInstall.php rename to modules/system/console/asset/AssetInstall.php index 1035c0ee0..273a512a1 100644 --- a/modules/system/console/MixInstall.php +++ b/modules/system/console/asset/AssetInstall.php @@ -1,61 +1,55 @@ - 'install', + 'completed' => 'installed', + ]; /** - * @var string The path to the "npm" executable. + * The NPM command to run. */ - protected $npmPath = 'npm'; + protected string $npmCommand = 'install'; /** - * @var string Default version of Laravel Mix to install + * Type of asset to be installed, @see CompilableAssets */ - protected $defaultMixVersion = '^6.0.41'; + protected string $assetType; /** - * @return array Terms used in messages. + * The asset config file */ - protected $terms = [ - 'complete' => 'install', - 'completed' => 'installed', - ]; + protected string $configFile; /** - * @var string The NPM command to run. + * The packages required for asset compilation */ - protected $npmCommand = 'install'; + protected array $packages; /** * Execute the console command. - * @return int */ public function handle(): int { @@ -68,10 +62,49 @@ public function handle(): int return 1; } - $mixedAssets = MixAssets::instance(); - $mixedAssets->fireCallbacks(); + [$requestedPackages, $registeredPackages] = $this->getRequestedAndRegisteredPackages(); - $registeredPackages = $mixedAssets->getPackages(); + if (!count($registeredPackages)) { + if (count($requestedPackages)) { + $this->error('No registered packages matched the requested packages for installation.'); + return 1; + } else { + $this->info('No packages registered for mixing.'); + return 0; + } + } + + // Load the main package.json for the project + $packageJsonPath = base_path('package.json'); + + // Get base package.json + $packageJson = new PackageJson($packageJsonPath); + // Ensure asset compiling packages are set in package.json, then save + $this->validateRequirePackagesPresent($packageJson) + ->save(); + // Process compilable asset packages, then save + $this->processPackages($registeredPackages, $packageJson) + ->save(); + + // Ensure separation between package.json modification messages and rest of output + $this->info(''); + + if ($this->installPackageDeps() !== 0) { + $this->error("Unable to {$this->terms['complete']} dependencies."); + return 1; + } + + $this->info("Dependencies successfully {$this->terms['completed']}!"); + + return 0; + } + + protected function getRequestedAndRegisteredPackages(): array + { + $compilableAssets = CompilableAssets::instance(); + $compilableAssets->fireCallbacks(); + + $registeredPackages = $compilableAssets->getPackages($this->assetType); $requestedPackages = $this->option('package') ?: []; // Normalize the requestedPackages option @@ -96,7 +129,11 @@ public function handle(): int // Check if package could be a module (but explicitly ignore core Winter modules) if (Str::startsWith($package, 'module-') && !in_array($package, ['system', 'backend', 'cms'])) { - $mixedAssets->registerPackage($package, base_path('modules/' . Str::after($package, 'module-') . '/winter.mix.js')); + $compilableAssets->registerPackage( + $package, + base_path('modules/' . Str::after($package, 'module-') . '/' . $this->configFile), + $this->assetType + ); continue; } @@ -107,19 +144,27 @@ public function handle(): int && Theme::exists(Str::after($package, 'theme-')) ) { $theme = Theme::load(Str::after($package, 'theme-')); - $mixedAssets->registerPackage($package, $theme->getPath() . '/winter.mix.js'); + $compilableAssets->registerPackage( + $package, + $theme->getPath() . '/' . $this->configFile, + $this->assetType + ); continue; } // Check if a package could be a plugin if (PluginManager::instance()->exists($package)) { - $mixedAssets->registerPackage($package, PluginManager::instance()->getPluginPath($package) . '/winter.mix.js'); + $compilableAssets->registerPackage( + $package, + PluginManager::instance()->getPluginPath($package) . '/' . $this->configFile, + $this->assetType + ); continue; } } // Get an updated list of packages including any newly added packages - $registeredPackages = $mixedAssets->getPackages(); + $registeredPackages = $compilableAssets->getPackages($this->assetType); // Filter the registered packages to only deal with the requested packages foreach (array_keys($registeredPackages) as $name) { @@ -129,47 +174,32 @@ public function handle(): int } } - if (!count($registeredPackages)) { - if (count($requestedPackages)) { - $this->error('No registered packages matched the requested packages for installation.'); - return 1; - } else { - $this->info('No packages registered for mixing.'); - return 0; + return [$requestedPackages, $registeredPackages]; + } + + protected function validateRequirePackagesPresent(PackageJson $packageJson): PackageJson + { + // Check to see if required packages are already present as a dependency + foreach ($this->packages as $package => $version) { + if ( + !$packageJson->hasDependency($package) + && $this->confirm($package . ' was not found as a dependency in package.json, would you like to add it?', true) + ) { + $packageJson->addDependency($package, $version, dev: true); } } - // Load the main package.json for the project - $packageJsonPath = base_path('package.json'); - $packageJson = []; - if (File::exists($packageJsonPath)) { - $packageJson = json_decode(File::get($packageJsonPath), true); - } - $workspacesPackages = $packageJson['workspaces']['packages'] ?? []; - $ignoredPackages = $packageJson['workspaces']['ignoredPackages'] ?? []; - - // Check to see if Laravel Mix is already present as a dependency - if ( - ( - !isset($packageJson['dependencies']['laravel-mix']) - && !isset($packageJson['devDependencies']['laravel-mix']) - ) - && $this->confirm('laravel-mix was not found as a dependency in package.json, would you like to add it?', true) - ) { - $packageJson['devDependencies'] = array_merge($packageJson['devDependencies'] ?? [], ['laravel-mix' => $this->defaultMixVersion]); - $this->writePackageJson($packageJsonPath, $packageJson); - } + return $packageJson; + } + protected function processPackages(array $registeredPackages, PackageJson $packageJson): PackageJson + { // Process each package foreach ($registeredPackages as $name => $package) { // Normalize package path across OS types $packagePath = Str::replace(DIRECTORY_SEPARATOR, '/', $package['path']); - // Add the package path to the instance's package.json->workspaces->packages property if not present - if ( - !in_array($packagePath, $workspacesPackages) - && !in_array($packagePath, $ignoredPackages) - ) { + if (!$packageJson->hasWorkspace($packagePath) && !$packageJson->hasIgnoredPackage($packagePath)) { if ( $this->confirm( sprintf( @@ -180,62 +210,37 @@ public function handle(): int true ) ) { - $workspacesPackages[] = $packagePath; + $packageJson->addWorkspace($packagePath); $this->info(sprintf( 'Adding %s (%s) to the workspaces.packages property in package.json', $name, $packagePath )); } else { - $ignoredPackages[] = $packagePath; + $packageJson->addIgnoredPackage($packagePath); $this->warn( sprintf('Ignoring %s (%s)', $name, $packagePath) ); } - asort($workspacesPackages); - asort($ignoredPackages); - $packageJson['workspaces']['packages'] = array_values($workspacesPackages); - $packageJson['workspaces']['ignoredPackages'] = array_values($ignoredPackages); - $this->writePackageJson($packageJsonPath, $packageJson); } - // Detect missing winter.mix.js files and install them - if (!File::exists($package['mix'])) { + // Detect missing config files and install them + if (!File::exists($package['config'])) { $this->info(sprintf( - 'No Mix file found for %s, creating one at %s...', + 'No config file found for %s, you should run %s:config', $name, - $package['mix'] + $this->assetType )); - File::put($package['mix'], File::get(__DIR__ . '/fixtures/winter.mix.js.fixture')); } } - // Ensure separation between package.json modification messages and rest of output - $this->info(''); - - if ($this->installPackageDeps() !== 0) { - $this->error("Unable to {$this->terms['complete']} dependencies."); - } else { - $this->info("Dependencies successfully {$this->terms['completed']}!"); - } - - return 0; - } - - /** - * Write to the package.json file - */ - protected function writePackageJson(string $path, array $data): void - { - File::put($path, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + return $packageJson; } /** * Installs the dependencies for the given package. - * - * @return int */ - protected function installPackageDeps() + protected function installPackageDeps(): int { $command = $this->argument('npmArgs') ?? []; array_unshift($command, 'npm', $this->npmCommand); @@ -267,11 +272,9 @@ protected function installPackageDeps() } /** - * Gets the install NPM version. - * - * @return string + * Gets the installed NPM version. */ - protected function getNpmVersion() + protected function getNpmVersion(): string { $process = new Process(['npm', '--version']); $process->run(); diff --git a/modules/system/console/MixList.php b/modules/system/console/asset/AssetList.php similarity index 57% rename from modules/system/console/MixList.php rename to modules/system/console/asset/AssetList.php index 23afc31c4..05c5f8b86 100644 --- a/modules/system/console/MixList.php +++ b/modules/system/console/asset/AssetList.php @@ -1,33 +1,21 @@ -fireCallbacks(); + $compilableAssets = CompilableAssets::instance(); + $compilableAssets->fireCallbacks(); - $packages = $mixedAssets->getPackages(true); + $packages = $compilableAssets->getPackages($this->assetType, true); if (count($packages) === 0) { $this->info('No packages have been registered.'); @@ -42,11 +30,11 @@ public function handle(): int 'name' => $name, 'active' => !$package['ignored'], 'path' => $package['path'], - 'configuration' => $package['mix'], + 'configuration' => $package['config'], ]; - if (!File::exists($package['mix'])) { - $errors[] = "The mix file for $name doesn't exist, try running artisan mix:install"; + if (!File::exists($package['config'])) { + $errors[] = "The config file for $name doesn't exist, try running artisan $this->assetType:install"; } } diff --git a/modules/system/console/asset/fixtures/config/css/default.css.fixture b/modules/system/console/asset/fixtures/config/css/default.css.fixture new file mode 100644 index 000000000..d28802876 --- /dev/null +++ b/modules/system/console/asset/fixtures/config/css/default.css.fixture @@ -0,0 +1,3 @@ +/** + * Css here + */ diff --git a/modules/system/console/asset/fixtures/config/css/tailwind.css.fixture b/modules/system/console/asset/fixtures/config/css/tailwind.css.fixture new file mode 100644 index 000000000..b5c61c956 --- /dev/null +++ b/modules/system/console/asset/fixtures/config/css/tailwind.css.fixture @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/modules/system/console/asset/fixtures/config/js/default.js.fixture b/modules/system/console/asset/fixtures/config/js/default.js.fixture new file mode 100644 index 000000000..526b86c79 --- /dev/null +++ b/modules/system/console/asset/fixtures/config/js/default.js.fixture @@ -0,0 +1 @@ +console.log('hello world!'); diff --git a/modules/system/console/asset/fixtures/config/js/vue.js.fixture b/modules/system/console/asset/fixtures/config/js/vue.js.fixture new file mode 100644 index 000000000..8d1fb32e9 --- /dev/null +++ b/modules/system/console/asset/fixtures/config/js/vue.js.fixture @@ -0,0 +1,8 @@ +import { createApp } from "vue"; +// import Example from "./components/Example.vue"; +// +// const app = createApp({ +// components: {Example} +// }); +// +// app.mount("#example"); diff --git a/modules/system/console/asset/fixtures/config/mix/winter.mix.js.fixture b/modules/system/console/asset/fixtures/config/mix/winter.mix.js.fixture new file mode 100644 index 000000000..de9acdc0c --- /dev/null +++ b/modules/system/console/asset/fixtures/config/mix/winter.mix.js.fixture @@ -0,0 +1,4 @@ +const mix = require('laravel-mix'); +mix.setPublicPath(__dirname); + +mix.js('assets/src/js/{{packageName}}.js', 'assets/dist/js/{{packageName}}.js'); diff --git a/modules/system/console/asset/fixtures/config/mix/winter.mix.tailwind.js.fixture b/modules/system/console/asset/fixtures/config/mix/winter.mix.tailwind.js.fixture new file mode 100644 index 000000000..cf9aec50c --- /dev/null +++ b/modules/system/console/asset/fixtures/config/mix/winter.mix.tailwind.js.fixture @@ -0,0 +1,10 @@ +const mix = require('laravel-mix'); +mix.setPublicPath(__dirname); + +mix.postCss('assets/src/css/{{packageName}}.css', 'assets/dist/css/{{packageName}}.css', [ + require('postcss-import'), + require('tailwindcss'), + require('autoprefixer'), +]); + +mix.js('assets/src/js/{{packageName}}.js', 'assets/dist/js/{{packageName}}.js'); diff --git a/modules/system/console/asset/fixtures/config/mix/winter.mix.tailwind.vue.js.fixture b/modules/system/console/asset/fixtures/config/mix/winter.mix.tailwind.vue.js.fixture new file mode 100644 index 000000000..74e63d4aa --- /dev/null +++ b/modules/system/console/asset/fixtures/config/mix/winter.mix.tailwind.vue.js.fixture @@ -0,0 +1,10 @@ +const mix = require('laravel-mix'); +mix.setPublicPath(__dirname); + +mix.postCss('assets/src/css/{{packageName}}.css', 'assets/dist/css/{{packageName}}.css', [ + require('postcss-import'), + require('tailwindcss'), + require('autoprefixer'), +]); + +mix.js('assets/src/js/{{packageName}}.js', 'assets/dist/js/{{packageName}}.js').vue({ version: 3 }); diff --git a/modules/system/console/asset/fixtures/config/mix/winter.mix.vue.js.fixture b/modules/system/console/asset/fixtures/config/mix/winter.mix.vue.js.fixture new file mode 100644 index 000000000..94fdace03 --- /dev/null +++ b/modules/system/console/asset/fixtures/config/mix/winter.mix.vue.js.fixture @@ -0,0 +1,4 @@ +const mix = require('laravel-mix'); +mix.setPublicPath(__dirname); + +mix.js('assets/src/js/{{packageName}}.js', 'assets/dist/js/{{packageName}}.js').vue({ version: 3 }); diff --git a/modules/system/console/asset/fixtures/config/tailwind/postcss.config.js.fixture b/modules/system/console/asset/fixtures/config/tailwind/postcss.config.js.fixture new file mode 100644 index 000000000..49c0612d5 --- /dev/null +++ b/modules/system/console/asset/fixtures/config/tailwind/postcss.config.js.fixture @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/modules/system/console/asset/fixtures/config/tailwind/tailwind.plugin.config.js.fixture b/modules/system/console/asset/fixtures/config/tailwind/tailwind.plugin.config.js.fixture new file mode 100644 index 000000000..5da548749 --- /dev/null +++ b/modules/system/console/asset/fixtures/config/tailwind/tailwind.plugin.config.js.fixture @@ -0,0 +1,14 @@ +import defaultTheme from 'tailwindcss/defaultTheme'; +import forms from '@tailwindcss/forms'; + +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + './blocks/**/*.block', + './components/**/*.{htm,php}', + './controllers/**/*.{htm,php}', + './formwidgets/**/*.{htm,php}', + './widgets/**/*.{htm,php}', + ], + plugins: [forms], +}; diff --git a/modules/system/console/asset/fixtures/config/tailwind/tailwind.theme.config.js.fixture b/modules/system/console/asset/fixtures/config/tailwind/tailwind.theme.config.js.fixture new file mode 100644 index 000000000..1bb457085 --- /dev/null +++ b/modules/system/console/asset/fixtures/config/tailwind/tailwind.theme.config.js.fixture @@ -0,0 +1,14 @@ +import defaultTheme from 'tailwindcss/defaultTheme'; +import forms from '@tailwindcss/forms'; + +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + './blocks/**/*.block', + './layouts/**/*.htm', + './pages/**/*.htm', + './partials/**/*.htm', + './content/**/*.htm', + ], + plugins: [forms], +}; diff --git a/modules/system/console/asset/fixtures/config/vite/vite.config.js.fixture b/modules/system/console/asset/fixtures/config/vite/vite.config.js.fixture new file mode 100644 index 000000000..58d9e327e --- /dev/null +++ b/modules/system/console/asset/fixtures/config/vite/vite.config.js.fixture @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; + +export default defineConfig({ + plugins: [ + laravel({ + input: [ + 'assets/src/css/{{packageName}}.css', + 'assets/src/js/{{packageName}}.js', + ], + refresh: { + paths: [ + './**/*.htm', + './**/*.block', + 'assets/**/*.css', + 'assets/**/*.js', + ] + }, + }), + ], +}); diff --git a/modules/system/console/asset/fixtures/config/vite/vite.config.tailwind.js.fixture b/modules/system/console/asset/fixtures/config/vite/vite.config.tailwind.js.fixture new file mode 100644 index 000000000..58d9e327e --- /dev/null +++ b/modules/system/console/asset/fixtures/config/vite/vite.config.tailwind.js.fixture @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; + +export default defineConfig({ + plugins: [ + laravel({ + input: [ + 'assets/src/css/{{packageName}}.css', + 'assets/src/js/{{packageName}}.js', + ], + refresh: { + paths: [ + './**/*.htm', + './**/*.block', + 'assets/**/*.css', + 'assets/**/*.js', + ] + }, + }), + ], +}); diff --git a/modules/system/console/asset/fixtures/config/vite/vite.config.tailwind.vue.js.fixture b/modules/system/console/asset/fixtures/config/vite/vite.config.tailwind.vue.js.fixture new file mode 100644 index 000000000..396bce8c7 --- /dev/null +++ b/modules/system/console/asset/fixtures/config/vite/vite.config.tailwind.vue.js.fixture @@ -0,0 +1,40 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; +import vue from '@vitejs/plugin-vue'; + +export default defineConfig({ + plugins: [ + laravel({ + input: [ + 'assets/src/css/{{packageName}}.css', + 'assets/src/js/{{packageName}}.js', + ], + refresh: { + paths: [ + './**/*.htm', + './**/*.block', + 'assets/**/*.css', + 'assets/**/*.js', + ] + }, + }), + vue({ + template: { + transformAssetUrls: { + // The Vue plugin will re-write asset URLs, when referenced + // in Single File Components, to point to the Laravel web + // server. Setting this to `null` allows the Laravel plugin + // to instead re-write asset URLs to point to the Vite + // server instead. + base: null, + + // The Vue plugin will parse absolute URLs and treat them + // as absolute paths to files on disk. Setting this to + // `false` will leave absolute URLs un-touched so they can + // reference assets in the public directory as expected. + includeAbsolute: false, + }, + }, + }), + ], +}); diff --git a/modules/system/console/asset/fixtures/config/vite/vite.config.vue.js.fixture b/modules/system/console/asset/fixtures/config/vite/vite.config.vue.js.fixture new file mode 100644 index 000000000..396bce8c7 --- /dev/null +++ b/modules/system/console/asset/fixtures/config/vite/vite.config.vue.js.fixture @@ -0,0 +1,40 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; +import vue from '@vitejs/plugin-vue'; + +export default defineConfig({ + plugins: [ + laravel({ + input: [ + 'assets/src/css/{{packageName}}.css', + 'assets/src/js/{{packageName}}.js', + ], + refresh: { + paths: [ + './**/*.htm', + './**/*.block', + 'assets/**/*.css', + 'assets/**/*.js', + ] + }, + }), + vue({ + template: { + transformAssetUrls: { + // The Vue plugin will re-write asset URLs, when referenced + // in Single File Components, to point to the Laravel web + // server. Setting this to `null` allows the Laravel plugin + // to instead re-write asset URLs to point to the Vite + // server instead. + base: null, + + // The Vue plugin will parse absolute URLs and treat them + // as absolute paths to files on disk. Setting this to + // `false` will leave absolute URLs un-touched so they can + // reference assets in the public directory as expected. + includeAbsolute: false, + }, + }, + }), + ], +}); diff --git a/modules/system/console/fixtures/mix.webpack.js.fixture b/modules/system/console/asset/fixtures/mix.webpack.js.fixture similarity index 100% rename from modules/system/console/fixtures/mix.webpack.js.fixture rename to modules/system/console/asset/fixtures/mix.webpack.js.fixture diff --git a/modules/system/console/fixtures/winter.mix.js.fixture b/modules/system/console/asset/fixtures/winter.mix.js.fixture similarity index 100% rename from modules/system/console/fixtures/winter.mix.js.fixture rename to modules/system/console/asset/fixtures/winter.mix.js.fixture diff --git a/modules/system/console/asset/mix/MixCompile.php b/modules/system/console/asset/mix/MixCompile.php new file mode 100644 index 000000000..29a65c9f9 --- /dev/null +++ b/modules/system/console/asset/mix/MixCompile.php @@ -0,0 +1,103 @@ +compileHandle('mix'); + } + + /** + * Create the command array to create a Process object with + */ + protected function createCommand(string $configPath): array + { + $basePath = base_path(); + $command = $this->argument('webpackArgs') ?? []; + array_unshift( + $command, + $basePath . sprintf('%1$snode_modules%1$s.bin%1$swebpack', DIRECTORY_SEPARATOR), + 'build', + $this->option('silent') ? '--stats=none' : '--progress', + '--config=' . $this->getJsConfigPath($configPath) + ); + + return $command; + } + + /** + * Create the temporary mix.webpack.js config file to run webpack with + */ + protected function beforeExecution(string $configPath): void + { + $basePath = base_path(); + $fixture = File::get(__DIR__ . '/../fixtures/mix.webpack.js.fixture'); + + $config = Str::swap([ + '%base%' => addslashes($basePath), + '%notificationInject%' => 'mix._api.disableNotifications();', + '%mixConfigPath%' => addslashes($configPath), + '%pluginsPath%' => addslashes(plugins_path()), + '%appPath%' => addslashes(base_path()), + '%silent%' => (int) $this->option('silent'), + '%noProgress%' => (int) $this->option('no-progress') + ], $fixture); + + File::put($this->getJsConfigPath($configPath), $config); + } + + /** + * Remove the temporary mix.webpack.js file + */ + protected function afterExecution(string $configPath): void + { + $webpackJsPath = $this->getPackagePath($configPath); + if (File::exists($webpackJsPath)) { + File::delete($webpackJsPath); + } + } +} diff --git a/modules/system/console/asset/mix/MixConfig.php b/modules/system/console/asset/mix/MixConfig.php new file mode 100644 index 000000000..051b8fcc7 --- /dev/null +++ b/modules/system/console/asset/mix/MixConfig.php @@ -0,0 +1,30 @@ + '^6.0.41' + ]; +} diff --git a/modules/system/console/asset/mix/MixList.php b/modules/system/console/asset/mix/MixList.php new file mode 100644 index 000000000..cd80927c8 --- /dev/null +++ b/modules/system/console/asset/mix/MixList.php @@ -0,0 +1,29 @@ +watchHandle('mix'); + } + + /** + * Create the command array to create a Process object with + */ + protected function createCommand(string $configPath): array + { + $command = parent::createCommand($configPath); + + // @TODO: Detect Homestead running on Windows to switch to watch-poll-options instead, see https://laravel-mix.com/docs/6.0/cli#polling + $command[] = '--watch'; + + return $command; + } + + /** + * Handle the cleanup of this command if a termination signal is received + */ + public function handleCleanup(): void + { + $this->newLine(); + $this->info('Cleaning up: ' . $this->getPackagePath(base_path($this->watchingFilePath))); + $this->afterExecution(base_path($this->watchingFilePath)); + } +} diff --git a/modules/system/console/asset/vite/ViteCompile.php b/modules/system/console/asset/vite/ViteCompile.php new file mode 100644 index 000000000..6e1044784 --- /dev/null +++ b/modules/system/console/asset/vite/ViteCompile.php @@ -0,0 +1,70 @@ +compileHandle('vite'); + } + + /** + * Create the command array to create a Process object with + */ + protected function createCommand(string $configPath): array + { + $basePath = base_path(); + $command = $this->argument('viteArgs') ?? []; + array_unshift( + $command, + $basePath . sprintf('%1$snode_modules%1$s.bin%1$svite', DIRECTORY_SEPARATOR), + 'build', + $this->option('silent') ? '--logLevel=silent' : '', + '--base=' . Str::after($this->getPackagePath($configPath), base_path()) + ); + + return $command; + } +} diff --git a/modules/system/console/asset/vite/ViteConfig.php b/modules/system/console/asset/vite/ViteConfig.php new file mode 100644 index 000000000..6d9051661 --- /dev/null +++ b/modules/system/console/asset/vite/ViteConfig.php @@ -0,0 +1,32 @@ + '^5.2.11', + 'laravel-vite-plugin' => '^1.0.4', + ]; +} diff --git a/modules/system/console/asset/vite/ViteList.php b/modules/system/console/asset/vite/ViteList.php new file mode 100644 index 000000000..a5f61a99a --- /dev/null +++ b/modules/system/console/asset/vite/ViteList.php @@ -0,0 +1,29 @@ +watchHandle('vite'); + } + + /** + * Create the command array to create a Process object with + */ + protected function createCommand(string $configPath): array + { + $command = parent::createCommand($configPath); + $key = array_search('build', $command); + unset($command[$key]); + + $command[] = '--host'; + + return array_values($command); + } + + /** + * Create the public dir if required + */ + protected function beforeExecution(string $configPath): void + { + $publicDir = dirname($configPath) . '/public'; + if (!File::exists($publicDir)) { + File::makeDirectory($publicDir); + } + } + + /** + * Handle the cleanup of this command if a termination signal is received + */ + public function handleCleanup(): void + { + $this->newLine(); + $this->info('Running compile to ensure files exist after exit'); + + $this->call('vite:compile', [ + '--package' => $this->argument('package'), + ]); + } +} diff --git a/modules/system/tests/classes/PackageJsonTest.php b/modules/system/tests/classes/PackageJsonTest.php new file mode 100644 index 000000000..661ed6d7e --- /dev/null +++ b/modules/system/tests/classes/PackageJsonTest.php @@ -0,0 +1,471 @@ +getContents(); + + $this->assertArrayHasKey('workspaces', $contents); + $this->assertArrayHasKey('packages', $contents['workspaces']); + $this->assertIsArray($contents['workspaces']['packages']); + } + + /** + * Test creating an instance with non-existing file + * + * @return void + */ + public function testNewFile(): void + { + $packageJson = new PackageJson(__DIR__ . '/../fixtures/npm/package-test-new.json'); + $contents = $packageJson->getContents(); + $this->assertIsArray($contents); + $this->assertCount(0, $contents); + } + + /** + * Test creating an instance without a path + * + * @return void + */ + public function testMemoryInstance(): void + { + $packageJson = new PackageJson(); + $contents = $packageJson->getContents(); + $this->assertIsArray($contents); + $this->assertCount(0, $contents); + } + + /** + * Test setting and getting the name property + * + * @return void + */ + public function testNameMethods(): void + { + $packageJson = new PackageJson(); + $contents = $packageJson->getContents(); + $this->assertIsArray($contents); + $this->assertCount(0, $contents); + + $packageJson->setName('example-name'); + + $this->assertEquals('example-name', $packageJson->getName()); + + $contents = $packageJson->getContents(); + $this->assertIsArray($contents); + $this->assertCount(1, $contents); + $this->assertArrayHasKey('name', $contents); + $this->assertEquals('example-name', $contents['name']); + } + + /** + * Test validating the name on set + * + * @return void + */ + public function testNameValidation(): void + { + $packageJson = new PackageJson(); + + $this->assertThrows(function () use ($packageJson) { + $packageJson->setName('Test'); + }, \InvalidArgumentException::class, 'Package names must be lower case'); + + $this->assertThrows(function () use ($packageJson) { + $packageJson->setName('.test'); + }, \InvalidArgumentException::class, 'Package names must not start with . or _'); + + $this->assertThrows(function () use ($packageJson) { + $packageJson->setName('_test'); + }, \InvalidArgumentException::class, 'Package names must not start with . or _'); + + $this->assertThrows(function () use ($packageJson) { + $packageJson->setName('te~st'); + }, \InvalidArgumentException::class, 'Package names must not include special characters'); + + $this->assertThrows(function () use ($packageJson) { + $packageJson->setName('te*st'); + }, \InvalidArgumentException::class, 'Package names must not include special characters'); + + $this->assertThrows(function () use ($packageJson) { + $packageJson->setName('test!'); + }, \InvalidArgumentException::class, 'Package names must not include special characters'); + + $this->assertThrows(function () use ($packageJson) { + $packageJson->setName(sprintf('te%sst', str_repeat('s', 214))); + }, \InvalidArgumentException::class, 'Package names must not be longer than 214 characters'); + + $this->assertThrows(function () use ($packageJson) { + $packageJson->setName('test '); + }, \InvalidArgumentException::class, 'Package names must not include whitespace'); + + $this->assertThrows(function () use ($packageJson) { + $packageJson->setName(' test'); + }, \InvalidArgumentException::class, 'Package names must not include whitespace'); + } + + /** + * Test checking package workspace exists + * + * @return void + */ + public function testHasWorkspace(): void + { + $packageJson = new PackageJson(__DIR__ . '/../fixtures/npm/package-test.json'); + $this->assertTrue($packageJson->hasWorkspace('themes/demo')); + $this->assertFalse($packageJson->hasWorkspace('themes/test')); + } + + /** + * Test adding workspace package + * + * @return void + */ + public function testAddWorkspace(): void + { + $packageJson = new PackageJson(__DIR__ . '/../fixtures/npm/package-test.json'); + $this->assertFalse($packageJson->hasWorkspace('themes/test')); + $packageJson->addWorkspace('themes/test'); + $this->assertTrue($packageJson->hasWorkspace('themes/test')); + + // Create blank source + $packageJson = new PackageJson(); + $this->assertFalse($packageJson->hasWorkspace('themes/test')); + $packageJson->addWorkspace('themes/test'); + $this->assertTrue($packageJson->hasWorkspace('themes/test')); + } + + /** + * Test removing workspace package + * + * @return void + */ + public function testRemoveWorkspace(): void + { + $packageJson = new PackageJson(__DIR__ . '/../fixtures/npm/package-test.json'); + $this->assertTrue($packageJson->hasWorkspace('themes/demo')); + $packageJson->removeWorkspace('themes/demo'); + $this->assertFalse($packageJson->hasWorkspace('themes/demo')); + + // Create blank source + $packageJson = new PackageJson(); + $packageJson->removeWorkspace('themes/demo'); + $this->assertFalse($packageJson->hasWorkspace('themes/demo')); + } + + /** + * Test when adding a workspace package, it removes the package from ignored workspace packages + * + * @return void + */ + public function testAddWorkspaceRemovesIgnoredPackage(): void + { + $packageJson = new PackageJson(__DIR__ . '/../fixtures/npm/package-test.json'); + $this->assertFalse($packageJson->hasWorkspace('modules/backend')); + $this->assertTrue($packageJson->hasIgnoredPackage('modules/backend')); + + $packageJson->addWorkspace('modules/backend'); + + $this->assertTrue($packageJson->hasWorkspace('modules/backend')); + $this->assertFalse($packageJson->hasIgnoredPackage('modules/backend')); + } + + /** + * Test checking ignore package workspace exists + * + * @return void + */ + public function testHasIgnoredPackage(): void + { + $packageJson = new PackageJson(__DIR__ . '/../fixtures/npm/package-test.json'); + $this->assertTrue($packageJson->hasIgnoredPackage('modules/backend')); + $this->assertFalse($packageJson->hasIgnoredPackage('modules/test')); + + // Create blank source + $packageJson = new PackageJson(); + $this->assertFalse($packageJson->hasIgnoredPackage('modules/backend')); + $this->assertFalse($packageJson->hasIgnoredPackage('modules/test')); + } + + /** + * Test adding ignore workspace package + * + * @return void + */ + public function testAddIgnoredPackage(): void + { + $packageJson = new PackageJson(__DIR__ . '/../fixtures/npm/package-test.json'); + $this->assertFalse($packageJson->hasIgnoredPackage('themes/example')); + $packageJson->addIgnoredPackage('themes/example'); + $this->assertTrue($packageJson->hasIgnoredPackage('themes/example')); + + // Create blank source + $packageJson = new PackageJson(); + $packageJson->addIgnoredPackage('themes/example'); + $this->assertTrue($packageJson->hasIgnoredPackage('themes/example')); + } + + /** + * Test removing ignore workspace package + * + * @return void + */ + public function testRemoveIgnoredPackage(): void + { + $packageJson = new PackageJson(__DIR__ . '/../fixtures/npm/package-test.json'); + $this->assertTrue($packageJson->hasIgnoredPackage('modules/system')); + $packageJson->removeIgnoredPackage('modules/system'); + $this->assertFalse($packageJson->hasIgnoredPackage('modules/system')); + + // Create blank source + $packageJson = new PackageJson(); + $packageJson->removeIgnoredPackage('modules/system'); + $this->assertFalse($packageJson->hasIgnoredPackage('modules/system')); + } + + /** + * Test when adding an ignore workspace package, it removes the package from workspace packages + * + * @return void + */ + public function testAddIgnoredPackageRemovesWorkspace(): void + { + $packageJson = new PackageJson(__DIR__ . '/../fixtures/npm/package-test.json'); + $this->assertTrue($packageJson->hasWorkspace('themes/demo')); + $this->assertFalse($packageJson->hasIgnoredPackage('themes/demo')); + + $packageJson->addIgnoredPackage('themes/demo'); + + $this->assertFalse($packageJson->hasWorkspace('themes/demo')); + $this->assertTrue($packageJson->hasIgnoredPackage('themes/demo')); + } + + /** + * Test checking if package.json has deps + * + * @return void + */ + public function testHasDependency(): void + { + $packageJson = new PackageJson(__DIR__ . '/../fixtures/npm/package-test.json'); + // Check that deps exist + $this->assertTrue($packageJson->hasDependency('test')); + $this->assertTrue($packageJson->hasDependency('test-dev')); + // Check that deps don't exist + $this->assertFalse($packageJson->hasWorkspace('test-dev2')); + $this->assertFalse($packageJson->hasWorkspace('testx')); + } + + /** + * Test adding dependencies, when overwriting check that package is moved from devDeps to deps or revsersed + * + * @return void + */ + public function testAddDependency(): void + { + // Test adding packages + $packageJson = new PackageJson(__DIR__ . '/../fixtures/npm/package-test.json'); + + $packageJson->addDependency('winter', '4.0.1', dev: false) + ->addDependency('winter-dev', '4.0.1', dev: true); + + $this->assertArrayNotHasKey('winter', $packageJson->getContents()['devDependencies']); + $this->assertArrayHasKey('winter', $packageJson->getContents()['dependencies']); + $this->assertArrayNotHasKey('winter-dev', $packageJson->getContents()['dependencies']); + $this->assertArrayHasKey('winter-dev', $packageJson->getContents()['devDependencies']); + + // Test adding packages with overwrites + $packageJson = new PackageJson(__DIR__ . '/../fixtures/npm/package-test.json'); + + // Should do nothing as test is already in deps not dev deps + $packageJson->addDependency('test', '6.0.1', dev: true, overwrite: false); + + $this->assertArrayHasKey('test', $packageJson->getContents()['dependencies']); + $this->assertArrayNotHasKey('test', $packageJson->getContents()['devDependencies']); + $this->assertEquals('^1.0.0', $packageJson->getContents()['dependencies']['test']); + + // Should move package from dev deps to deps with new version + $packageJson->addDependency('test', '6.0.1', dev: true, overwrite: true); + + $this->assertArrayNotHasKey('test', $packageJson->getContents()['dependencies']); + $this->assertArrayHasKey('test', $packageJson->getContents()['devDependencies']); + $this->assertEquals('6.0.1', $packageJson->getContents()['devDependencies']['test']); + + // Create blank source + $packageJson = new PackageJson(); + // Add a non-dev dependency + $packageJson->addDependency('winter', '4.0.1', dev: false); + // Add a dev dependency + $packageJson->addDependency('winter-dev', '4.0.1', dev: true); + } + + /** + * Test removing dependencies + * + * @return void + */ + public function testRemoveDependency(): void + { + $packageJson = new PackageJson(__DIR__ . '/../fixtures/npm/package-test.json'); + + // Check package removed from dev deps + $this->assertArrayHasKey('test-dev', $packageJson->getContents()['devDependencies'] ?? []); + $packageJson->removeDependency('test-dev'); + $this->assertArrayNotHasKey('test-dev', $packageJson->getContents()['devDependencies'] ?? []); + + // Check package removed from deps + $this->assertArrayHasKey('test', $packageJson->getContents()['dependencies'] ?? []); + $packageJson->removeDependency('test'); + $this->assertArrayNotHasKey('test', $packageJson->getContents()['dependencies'] ?? []); + + // Create blank source + $packageJson = new PackageJson(); + $packageJson->removeDependency('test-dev'); + $this->assertArrayNotHasKey('test-dev', $packageJson->getContents()['devDependencies'] ?? []); + } + + /** + * Test checking if a script exists in a package.json + * + * @return void + */ + public function testHasScript(): void + { + $packageJson = new PackageJson(__DIR__ . '/../fixtures/npm/package-test.json'); + + $this->assertTrue($packageJson->hasScript('foo')); + $this->assertTrue($packageJson->hasScript('example')); + $this->assertTrue($packageJson->hasScript('test')); + + $this->assertFalse($packageJson->hasScript('bar')); + $this->assertFalse($packageJson->hasScript('winter')); + $this->assertFalse($packageJson->hasScript('testing')); + } + + /** + * Test getting the value of a script by name + * + * @return void + */ + public function testGetScript(): void + { + $packageJson = new PackageJson(__DIR__ . '/../fixtures/npm/package-test.json'); + + $this->assertEquals('bar ./test', $packageJson->getScript('foo')); + $this->assertEquals('example test', $packageJson->getScript('example')); + $this->assertEquals('testing', $packageJson->getScript('test')); + + $packageJson = new PackageJson(); + $this->assertNull($packageJson->getScript('foo')); + } + + /** + * Test getting the value of a script by name + * + * @return void + */ + public function testAddScript(): void + { + $packageJson = new PackageJson(__DIR__ . '/../fixtures/npm/package-test.json'); + + $packageJson->addScript('winter', 'winter ./testing'); + + $this->assertTrue($packageJson->hasScript('winter')); + $this->assertEquals('winter ./testing', $packageJson->getScript('winter')); + + $contents = $packageJson->getContents(); + + $this->assertTrue(isset($contents['scripts']['winter'])); + $this->assertEquals('winter ./testing', $contents['scripts']['winter']); + + $packageJson = new PackageJson(); + $packageJson->addScript('winter', 'winter ./testing'); + $this->assertTrue($packageJson->hasScript('winter')); + } + + /** + * Test removing scripts from package.json + * + * @return void + */ + public function testRemoveScript(): void + { + $packageJson = new PackageJson(__DIR__ . '/../fixtures/npm/package-test.json'); + + $this->assertTrue($packageJson->hasScript('foo')); + $packageJson->removeScript('foo'); + $this->assertFalse($packageJson->hasScript('foo')); + + $this->assertTrue($packageJson->hasScript('example')); + $packageJson->removeScript('example'); + $this->assertFalse($packageJson->hasScript('example')); + + + $packageJson = new PackageJson(); + $packageJson->removeScript('foo'); + $this->assertFalse($packageJson->hasScript('foo')); + } + + /** + * Test saving, when saving with a file path set on init and passing a file path on save. Fails when no path given + * + * @return void + */ + public function testSave(): void + { + $srcFile = __DIR__ . '/../fixtures/npm/package-test.json'; + $backupFile = __DIR__ . '/../fixtures/npm/package-test.json.back'; + + // Backup config file + copy($srcFile, $backupFile); + + // Make a change and save the file, overwriting + $packageJson = new PackageJson($srcFile); + $this->assertNull($packageJson->getName()); + $packageJson->setName('testing'); + $packageJson->save(); + + // Validate overwrite worked + $packageJson = new PackageJson($srcFile); + $this->assertEquals('testing', $packageJson->getName()); + + // Restore the config file + copy($backupFile, $srcFile); + // Remove backup file + unlink($backupFile); + + // Test saving file to new path + $testFile = __DIR__ . '/../fixtures/npm/package-test.json.test'; + $packageJson = new PackageJson($srcFile); + $this->assertNull($packageJson->getName()); + $packageJson->setName('testing'); + $packageJson->save($testFile); + + // Validate new file path exists and contains change + $packageJson = new PackageJson($testFile); + $this->assertEquals('testing', $packageJson->getName()); + + // Remove test file + unlink($testFile); + + // Validate that a file save with no path throws an error + $this->assertThrows(function () { + $packageJson = new PackageJson(); + $packageJson->setName('should-fail'); + $packageJson->save(); + }, \RuntimeException::class, 'Unable to save, no path given'); + } +} diff --git a/modules/system/tests/console/MixCompileTest.php b/modules/system/tests/console/MixCompileTest.php index 095f720ec..4d7b6bf28 100644 --- a/modules/system/tests/console/MixCompileTest.php +++ b/modules/system/tests/console/MixCompileTest.php @@ -2,15 +2,13 @@ namespace System\Tests\Console; -use File; -use System\Classes\MixAssets; -use System\Console\MixCompile; +use Winter\Storm\Support\Facades\File; use System\Tests\Bootstrap\TestCase; -use Symfony\Component\Console\Input\ArrayInput; -use Symfony\Component\Console\Output\BufferedOutput; class MixCompileTest extends TestCase { + protected string $command = 'mix:compile'; + public function setUp(): void { parent::setUp(); @@ -22,30 +20,24 @@ public function setUp(): void public function testCompileMultiple() { - [$command, $output] = $this->makeCommand(); - - $result = $command->run(new ArrayInput([ + $this->artisan($this->command, [ '--manifest' => 'modules/system/tests/fixtures/npm/package-ac.json', '--silent' => true - ]), $output); + ])->assertExitCode(0); - $this->assertIsInt($result); - $this->assertEquals(0, $result); $this->assertFileExists(base_path('modules/system/tests/fixtures/plugins/mix/testa/assets/dist/app.js')); $this->assertFileExists(base_path('modules/system/tests/fixtures/plugins/mix/testc/assets/dist/app.js')); } public function testCompileMultipleWithErrors() { - [$command, $output] = $this->makeCommand(); - - $result = $command->run(new ArrayInput([ + $this->artisan($this->command, [ '--manifest' => 'modules/system/tests/fixtures/npm/package-abc.json', - '--silent' => true - ]), $output); + '--disable-tty' => true + ]) + ->expectsOutputToContain('Error: Can\'t resolve \'some-missing-package\'') + ->assertExitCode(1); - $this->assertIsInt($result); - $this->assertEquals(1, $result); $this->assertFileExists(base_path('modules/system/tests/fixtures/plugins/mix/testa/assets/dist/app.js')); $this->assertFileExists(base_path('modules/system/tests/fixtures/plugins/mix/testb/assets/dist/app.js')); $this->assertFileExists(base_path('modules/system/tests/fixtures/plugins/mix/testc/assets/dist/app.js')); @@ -53,16 +45,12 @@ public function testCompileMultipleWithErrors() public function testCompileTarget() { - [$command, $output] = $this->makeCommand(); - - $result = $command->run(new ArrayInput([ + $this->artisan($this->command, [ '--manifest' => 'modules/system/tests/fixtures/npm/package-abc.json', '--package' => 'mix.testa', '--silent' => true - ]), $output); + ])->assertExitCode(0); - $this->assertIsInt($result); - $this->assertEquals(0, $result); $this->assertFileExists(base_path('modules/system/tests/fixtures/plugins/mix/testa/assets/dist/app.js')); $this->assertFileNotExists(base_path('modules/system/tests/fixtures/plugins/mix/testb/assets/dist/app.js')); $this->assertFileNotExists(base_path('modules/system/tests/fixtures/plugins/mix/testc/assets/dist/app.js')); @@ -70,16 +58,14 @@ public function testCompileTarget() public function testCompileTargetWithError() { - [$command, $output] = $this->makeCommand(); - - $result = $command->run(new ArrayInput([ + $this->artisan($this->command, [ '--manifest' => 'modules/system/tests/fixtures/npm/package-abc.json', '--package' => 'mix.testb', - '--silent' => true - ]), $output); + '--disable-tty' => true + ]) + ->expectsOutputToContain('Error: Can\'t resolve \'some-missing-package\'') + ->assertExitCode(1); - $this->assertIsInt($result); - $this->assertEquals(1, $result); $this->assertFileNotExists(base_path('modules/system/tests/fixtures/plugins/mix/testa/assets/dist/app.js')); $this->assertFileNotExists(base_path('modules/system/tests/fixtures/plugins/mix/testc/assets/dist/app.js')); $this->assertFileExists(base_path('modules/system/tests/fixtures/plugins/mix/testb/assets/dist/app.js')); @@ -87,29 +73,19 @@ public function testCompileTargetWithError() public function testCompileTargetStopOnError() { - [$command, $output] = $this->makeCommand(); - - $result = $command->run(new ArrayInput([ + $this->artisan($this->command, [ '--manifest' => 'modules/system/tests/fixtures/npm/package-abc.json', '--stop-on-error' => true, - '--silent' => true - ]), $output); + '--disable-tty' => true + ]) + ->expectsOutputToContain('Error: Can\'t resolve \'some-missing-package\'') + ->assertExitCode(1); - $this->assertIsInt($result); - $this->assertEquals(1, $result); $this->assertFileExists(base_path('modules/system/tests/fixtures/plugins/mix/testa/assets/dist/app.js')); $this->assertFileExists(base_path('modules/system/tests/fixtures/plugins/mix/testb/assets/dist/app.js')); $this->assertFileNotExists(base_path('modules/system/tests/fixtures/plugins/mix/testc/assets/dist/app.js')); } - protected function makeCommand(): array - { - $output = new BufferedOutput(); - $command = new MixCompile(); - $command->setLaravel($this->app); - return [$command, $output]; - } - public function tearDown(): void { File::deleteDirectory('modules/system/tests/fixtures/plugins/mix/testa/assets/dist'); diff --git a/modules/system/tests/console/MixConfigTest.php b/modules/system/tests/console/MixConfigTest.php new file mode 100644 index 000000000..fd1eb8092 --- /dev/null +++ b/modules/system/tests/console/MixConfigTest.php @@ -0,0 +1,168 @@ +findByIdentifier($this->testPlugin)->getPluginPath(); + $configPath = $path . '/winter.mix.js'; + + // Check file does not exist + $this->assertFileNotExists($configPath); + + // Run the config command to generate the vite config + $this->artisan('mix:config', [ + 'packageName' => $this->testPlugin, + ]) + ->doesntExpectOutput('winter.mix.js already exists, overwrite?') + ->assertExitCode(0); + + // Validate the manifest was written + $this->assertFileExists($configPath); + + // Get the predicted contents + $fixture = str_replace( + '{{packageName}}', + 'winter-sample', + File::get(base_path('modules/system/console/asset/fixtures/config/mix/winter.mix.js.fixture')), + ); + + // Check the file written is what was expected + $this->assertEquals($fixture, File::get($configPath)); + + // Overwrite the file content + File::put($configPath, 'testing'); + + // Check that refusing to overwrite does not replace file contents + $this->artisan('mix:config', [ + 'packageName' => $this->testPlugin, + ]) + ->expectsQuestion('winter.mix.js already exists, overwrite?', false) + ->assertExitCode(0); + + // Check file contents was not overwritten + $this->assertNotEquals($fixture, File::get($configPath)); + + // Run command confirming to overwrite file contents works + $this->artisan('mix:config', [ + 'packageName' => $this->testPlugin, + ]) + ->expectsQuestion('winter.mix.js already exists, overwrite?', true) + ->assertExitCode(0); + + // Check file contents was overwritten + $this->assertEquals($fixture, File::get($configPath)); + } + + public function testConfigTailwind(): void + { + $path = PluginManager::instance()->findByIdentifier($this->testPlugin)->getPluginPath(); + $configPath = $path . '/winter.mix.js'; + $packageJson = $path . '/package.json'; + + // Check file does not exist + $this->assertFileNotExists($configPath); + + // Run the config command to generate the vite config + $this->artisan('mix:config', [ + 'packageName' => $this->testPlugin, + '--tailwind' => true + ]) + ->assertExitCode(0); + + // Check files are created correctly + $this->assertFileExists($configPath); + $this->assertFileExists($path . '/tailwind.config.js'); + $this->assertFileExists($path . '/postcss.config.mjs'); + + // Get the contents of the package.json + $json = json_decode(File::get($packageJson)); + + // Check tailwindcss is required + $this->assertTrue(isset($json->devDependencies->tailwindcss)); + } + + public function testConfigVue(): void + { + $path = PluginManager::instance()->findByIdentifier($this->testPlugin)->getPluginPath(); + $configPath = $path . '/winter.mix.js'; + $packageJson = $path . '/package.json'; + + // Check file does not exist + $this->assertFileNotExists($configPath); + + // Run the config command to generate the vite config with vue + $this->artisan('mix:config', [ + 'packageName' => $this->testPlugin, + '--vue' => true + ]) + ->assertExitCode(0); + + // Check files are created correctly + $this->assertFileExists($configPath); + $this->assertFileExists($packageJson); + + // Get the contents of the package.json + $json = json_decode(File::get($packageJson)); + + // Check vue is required + $this->assertTrue(isset($json->devDependencies->vue)); + } + + public function testConfigTailwindVue(): void + { + $path = PluginManager::instance()->findByIdentifier($this->testPlugin)->getPluginPath(); + $configPath = $path . '/winter.mix.js'; + $packageJson = $path . '/package.json'; + + // Check file does not exist + $this->assertFileNotExists($configPath); + + // Run the config command to generate the vite config with vue + $this->artisan('mix:config', [ + 'packageName' => $this->testPlugin, + '--tailwind' => true, + '--vue' => true + ]) + ->assertExitCode(0); + + // Check files are created correctly + $this->assertFileExists($configPath); + $this->assertFileExists($packageJson); + $this->assertFileExists($path . '/tailwind.config.js'); + $this->assertFileExists($path . '/postcss.config.mjs'); + + // Get the contents of the package.json + $json = json_decode(File::get($packageJson)); + + // Check tailwind & vue are required + $this->assertTrue(isset($json->devDependencies->tailwindcss)); + $this->assertTrue(isset($json->devDependencies->vue)); + } + + public function tearDown(): void + { + $path = PluginManager::instance()->findByIdentifier($this->testPlugin)->getPluginPath(); + + $files = [ + $path . '/winter.mix.js', + $path . '/package.json', + $path . '/tailwind.config.js', + $path . '/postcss.config.mjs', + ]; + + foreach ($files as $file) { + if (File::exists($file)) { + File::delete($file); + } + } + } +} diff --git a/modules/system/tests/console/ViteCompileTest.php b/modules/system/tests/console/ViteCompileTest.php new file mode 100644 index 000000000..11a1abb8d --- /dev/null +++ b/modules/system/tests/console/ViteCompileTest.php @@ -0,0 +1,114 @@ +markTestSkipped('This test requires node_modules to be installed'); + } + + $this->themePath = base_path('modules/system/tests/fixtures/themes/vitetest'); + + // Add our testing theme because it won't be auto discovered + CompilableAssets::instance()->registerPackage( + 'theme-vitetest', + $this->themePath . '/vite.config.mjs', + 'vite' + ); + } + + public function testThemeCompile(): void + { + // Run the vite:compile command + $this->artisan('vite:compile', [ + 'theme-vitetest', + '--manifest' => 'modules/system/tests/fixtures/npm/package-vitetheme.json', + '--silent' => true + ])->assertExitCode(0); + + $manifestPath = $this->themePath . '/public/build/manifest.json'; + + // Validate the manifest was written + $this->assertFileExists($manifestPath); + + // Get the contents of the manifest + $manifest = json_decode(File::get($manifestPath), JSON_OBJECT_AS_ARRAY); + + // Validate the css was compiled correctly + $this->assertArrayHasKey('assets/css/theme.css', $manifest); + $this->assertFileExists($this->themePath . '/public/build/' . $manifest['assets/css/theme.css']['file']); + $this->assertEquals( + 'h1{color:red}', + trim(File::get($this->themePath . '/public/build/' . $manifest['assets/css/theme.css']['file'])) + ); + + // Validate the js was compiled correctly + $this->assertArrayHasKey('assets/javascript/theme.js', $manifest); + $this->assertFileExists($this->themePath . '/public/build/' . $manifest['assets/javascript/theme.js']['file']); + $this->assertEquals( + 'window.alert("hello world");', + trim(File::get($this->themePath . '/public/build/' . $manifest['assets/javascript/theme.js']['file'])) + ); + } + + public function testThemeCompileFailed(): void + { + // Rename the css file so vite cannot find it + File::move($this->themePath . '/assets/css/theme.css', $this->themePath . '/assets/css/theme.back'); + + // Run the vite:compile command + $this->artisan('vite:compile', [ + 'theme-vitetest', + '--manifest' => 'modules/system/tests/fixtures/npm/package-vitetheme.json', + '--disable-tty' => true + ]) + ->expectsOutputToContain('Could not resolve entry module "assets/css/theme.css".') + ->assertExitCode(1); + + // Validate the manifest was not written + $this->assertFileNotExists($this->themePath . '/public/build/manifest.json'); + + // Put the css file back + File::move($this->themePath . '/assets/css/theme.back', $this->themePath . '/assets/css/theme.css'); + } + + public function testThemeCompileWarning(): void + { + // Rename the css file so vite cannot find it + $contents = File::get($this->themePath . '/assets/css/theme.css'); + + File::put($this->themePath . '/assets/css/theme.css', 'h1 {color:'); + + // Run the vite:compile command + $this->artisan('vite:compile', [ + 'theme-vitetest', + '--manifest' => 'modules/system/tests/fixtures/npm/package-vitetheme.json', + '--disable-tty' => true + ]) + ->expectsOutputToContain('[WARNING] Expected "}" to go with "{" [css-syntax-error]') + ->assertExitCode(0); + + // Validate the manifest was not written + $this->assertFileExists($this->themePath . '/public/build/manifest.json'); + + // Put the css file back + File::put($this->themePath . '/assets/css/theme.css', $contents); + } + + public function tearDown(): void + { + File::deleteDirectory('modules/system/tests/fixtures/themes/vitetest/public'); + parent::tearDown(); + } +} diff --git a/modules/system/tests/console/ViteConfigTest.php b/modules/system/tests/console/ViteConfigTest.php new file mode 100644 index 000000000..9f629b2bd --- /dev/null +++ b/modules/system/tests/console/ViteConfigTest.php @@ -0,0 +1,168 @@ +findByIdentifier($this->testPlugin)->getPluginPath(); + $configPath = $path . '/vite.config.mjs'; + + // Check file does not exist + $this->assertFileNotExists($configPath); + + // Run the config command to generate the vite config + $this->artisan('vite:config', [ + 'packageName' => $this->testPlugin, + ]) + ->doesntExpectOutput('vite.config.mjs already exists, overwrite?') + ->assertExitCode(0); + + // Validate the manifest was written + $this->assertFileExists($configPath); + + // Get the predicted contents + $fixture = str_replace( + '{{packageName}}', + 'winter-sample', + File::get(base_path('modules/system/console/asset/fixtures/config/vite/vite.config.js.fixture')), + ); + + // Check the file written is what was expected + $this->assertEquals($fixture, File::get($configPath)); + + // Overwrite the file content + File::put($configPath, 'testing'); + + // Check that refusing to overwrite does not replace file contents + $this->artisan('vite:config', [ + 'packageName' => $this->testPlugin, + ]) + ->expectsQuestion('vite.config.mjs already exists, overwrite?', false) + ->assertExitCode(0); + + // Check file contents was not overwritten + $this->assertNotEquals($fixture, File::get($configPath)); + + // Run command confirming to overwrite file contents works + $this->artisan('vite:config', [ + 'packageName' => $this->testPlugin, + ]) + ->expectsQuestion('vite.config.mjs already exists, overwrite?', true) + ->assertExitCode(0); + + // Check file contents was overwritten + $this->assertEquals($fixture, File::get($configPath)); + } + + public function testConfigTailwind(): void + { + $path = PluginManager::instance()->findByIdentifier($this->testPlugin)->getPluginPath(); + $configPath = $path . '/vite.config.mjs'; + $packageJson = $path . '/package.json'; + + // Check file does not exist + $this->assertFileNotExists($configPath); + + // Run the config command to generate the vite config + $this->artisan('vite:config', [ + 'packageName' => $this->testPlugin, + '--tailwind' => true + ]) + ->assertExitCode(0); + + // Check files are created correctly + $this->assertFileExists($configPath); + $this->assertFileExists($path . '/tailwind.config.js'); + $this->assertFileExists($path . '/postcss.config.mjs'); + + // Get the contents of the package.json + $json = json_decode(File::get($packageJson)); + + // Check tailwindcss is required + $this->assertTrue(isset($json->devDependencies->tailwindcss)); + } + + public function testConfigVue(): void + { + $path = PluginManager::instance()->findByIdentifier($this->testPlugin)->getPluginPath(); + $configPath = $path . '/vite.config.mjs'; + $packageJson = $path . '/package.json'; + + // Check file does not exist + $this->assertFileNotExists($configPath); + + // Run the config command to generate the vite config with vue + $this->artisan('vite:config', [ + 'packageName' => $this->testPlugin, + '--vue' => true + ]) + ->assertExitCode(0); + + // Check files are created correctly + $this->assertFileExists($configPath); + $this->assertFileExists($packageJson); + + // Get the contents of the package.json + $json = json_decode(File::get($packageJson)); + + // Check vue is required + $this->assertTrue(isset($json->devDependencies->vue)); + } + + public function testConfigTailwindVue(): void + { + $path = PluginManager::instance()->findByIdentifier($this->testPlugin)->getPluginPath(); + $configPath = $path . '/vite.config.mjs'; + $packageJson = $path . '/package.json'; + + // Check file does not exist + $this->assertFileNotExists($configPath); + + // Run the config command to generate the vite config with vue + $this->artisan('vite:config', [ + 'packageName' => $this->testPlugin, + '--tailwind' => true, + '--vue' => true + ]) + ->assertExitCode(0); + + // Check files are created correctly + $this->assertFileExists($configPath); + $this->assertFileExists($packageJson); + $this->assertFileExists($path . '/tailwind.config.js'); + $this->assertFileExists($path . '/postcss.config.mjs'); + + // Get the contents of the package.json + $json = json_decode(File::get($packageJson)); + + // Check tailwind & vue are required + $this->assertTrue(isset($json->devDependencies->tailwindcss)); + $this->assertTrue(isset($json->devDependencies->vue)); + } + + public function tearDown(): void + { + $path = PluginManager::instance()->findByIdentifier($this->testPlugin)->getPluginPath(); + + $files = [ + $path . '/vite.config.mjs', + $path . '/package.json', + $path . '/tailwind.config.js', + $path . '/postcss.config.mjs', + ]; + + foreach ($files as $file) { + if (File::exists($file)) { + File::delete($file); + } + } + } +} diff --git a/modules/system/tests/console/WinterEnvTest.php b/modules/system/tests/console/WinterEnvTest.php index 034057223..4b2bdc313 100644 --- a/modules/system/tests/console/WinterEnvTest.php +++ b/modules/system/tests/console/WinterEnvTest.php @@ -4,9 +4,6 @@ use System\Tests\Bootstrap\TestCase; use Winter\Storm\Foundation\Bootstrap\LoadConfiguration; -use Symfony\Component\Console\Input\ArrayInput; -use Symfony\Component\Console\Output\BufferedOutput; -use System\Console\WinterEnv; class WinterEnvTest extends TestCase { @@ -28,15 +25,8 @@ protected function setUp(): void public function testCommand() { - $output = new BufferedOutput(); - $command = new WinterEnv(); - $command->setLaravel($this->app); - $result = $command->run(new ArrayInput([]), $output); - - // Ensure that the command actually succeeded - if ($result !== 0) { - throw new \Exception("Command failed: \r\n" . $output->fetch()); - } + $this->artisan('winter:env') + ->assertExitCode(0); // Check environment file $envFile = file_get_contents($this->app->environmentFilePath()); diff --git a/modules/system/tests/fixtures/npm/package-test.json b/modules/system/tests/fixtures/npm/package-test.json new file mode 100644 index 000000000..e2799f2e5 --- /dev/null +++ b/modules/system/tests/fixtures/npm/package-test.json @@ -0,0 +1,24 @@ +{ + "workspaces": { + "packages": [ + "plugins/winter/demo", + "themes/demo" + ], + "ignoredPackages": [ + "modules/backend", + "modules/system" + ] + }, + "dependencies": { + "example": "^2.0.1", + "test": "^1.0.0" + }, + "devDependencies": { + "test-dev": "^3.0.2" + }, + "scripts": { + "test": "testing", + "example": "example test", + "foo": "bar ./test" + } +} diff --git a/modules/system/tests/fixtures/npm/package-vitetheme.json b/modules/system/tests/fixtures/npm/package-vitetheme.json new file mode 100644 index 000000000..7eb3504b8 --- /dev/null +++ b/modules/system/tests/fixtures/npm/package-vitetheme.json @@ -0,0 +1,12 @@ +{ + "type": "module", + "workspaces": { + "packages": [ + "modules/system/tests/fixtures/themes/vitetest" + ] + }, + "devDependencies": { + "laravel-vite-plugin": "^1.0.2", + "vite": "^5.2.11" + } +} diff --git a/modules/system/tests/fixtures/themes/vitetest/assets/css/theme.css b/modules/system/tests/fixtures/themes/vitetest/assets/css/theme.css new file mode 100644 index 000000000..d224431f1 --- /dev/null +++ b/modules/system/tests/fixtures/themes/vitetest/assets/css/theme.css @@ -0,0 +1,3 @@ +h1 { + color: red; +} diff --git a/modules/system/tests/fixtures/themes/vitetest/assets/javascript/theme.js b/modules/system/tests/fixtures/themes/vitetest/assets/javascript/theme.js new file mode 100644 index 000000000..bbee1c480 --- /dev/null +++ b/modules/system/tests/fixtures/themes/vitetest/assets/javascript/theme.js @@ -0,0 +1 @@ +window.alert(`hello world`) diff --git a/modules/system/tests/fixtures/themes/vitetest/vite.config.mjs b/modules/system/tests/fixtures/themes/vitetest/vite.config.mjs new file mode 100644 index 000000000..b34bf6ac2 --- /dev/null +++ b/modules/system/tests/fixtures/themes/vitetest/vite.config.mjs @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; + +export default defineConfig({ + plugins: [ + laravel({ + input: [ + 'assets/css/theme.css', + 'assets/javascript/theme.js', + ], + refresh: { + paths: [ + './**/*.htm', + 'assets/**/*.css', + 'assets/**/*.js', + ] + }, + }) + ], +}); diff --git a/modules/system/traits/AssetMaker.php b/modules/system/traits/AssetMaker.php index 58c2af8a1..0a08b2eb8 100644 --- a/modules/system/traits/AssetMaker.php +++ b/modules/system/traits/AssetMaker.php @@ -3,8 +3,11 @@ namespace System\Traits; use System\Classes\CombineAssets; +use System\Classes\PluginManager; +use System\Classes\Vite; use System\Models\Parameter; use System\Models\PluginVersion; +use Winter\Storm\Exception\SystemException; use Winter\Storm\Support\Facades\Event; use Winter\Storm\Support\Facades\File; use Winter\Storm\Support\Facades\Html; @@ -22,7 +25,7 @@ trait AssetMaker /** * Collection of assets to display in the layout. */ - protected array $assets = ['js' => [], 'css' => [], 'rss' => []]; + protected array $assets = ['js' => [], 'css' => [], 'rss' => [], 'vite' => []]; /** * @var string Specifies a path to the asset directory. @@ -41,7 +44,7 @@ trait AssetMaker */ public function flushAssets(): void { - $this->assets = ['js' => [], 'css' => [], 'rss' => []]; + $this->assets = ['js' => [], 'css' => [], 'rss' => [], 'vite' => []]; } /** @@ -101,6 +104,12 @@ public function makeAssets(string $type = null): ?string } } + if ($type == null || $type == 'vite') { + foreach ($this->assets['vite'] as $asset) { + $result .= Vite::tags($asset['attributes']['entrypoints'], $asset['path']); + } + } + return $result; } @@ -187,6 +196,32 @@ public function addRss(string $name, array|string $attributes = []): void $this->addAsset('rss', $rssPath, $attributes); } + /** + * Adds Vite tags + * @param string|array $entrypoints The list of entry points for Vite + * @param ?string $package The package name of the plugin or theme + */ + public function addVite(array|string $entrypoints, ?string $package = null): void + { + if (!is_array($entrypoints)) { + $entrypoints = [$entrypoints]; + } + + // If package was not set, attempt to guess + if (is_null($package)) { + $caller = get_called_class(); + if (!($plugin = PluginManager::instance()->findByNamespace($caller))) { + throw new SystemException('Unable to determine vite package from namespace: ' . $caller); + } + // Set package to the plugin id + $package = $plugin->getPluginIdentifier(); + } + + $this->addAsset('vite', $package, [ + 'entrypoints' => $entrypoints + ]); + } + /** * Adds the provided asset to the internal asset collections */