setName('vue:build'); $this->setDescription('Builds vue modules for one or more plugins.'); $this->addOptionalArgument('plugins', 'Plugins whose vue modules to build. Defaults to all plugins.', [], true); $this->addNoValueOption('bail', null, 'If supplied, will exit immediately.'); $this->addNoValueOption('watch', null, 'If supplied, will watch for changes and automatically rebuild.'); $this->addNoValueOption('clear-webpack-cache'); $this->addNoValueOption('print-build-command'); } public function isEnabled() { return \Piwik\Development::isEnabled(); } protected function doExecute(): int { $input = $this->getInput(); $output = $this->getOutput(); self::checkVueCliServiceAvailable(); $this->checkNodeJsVersion(); $clearWebpackCache = $input->getOption('clear-webpack-cache'); if ($clearWebpackCache) { $this->clearWebpackCache(); } $printBuildCommand = $input->getOption('print-build-command'); $watch = $input->getOption('watch'); $allPluginNames = $this->getAllPlugins(); $allPluginNames = $this->filterPluginsWithoutVueLibrary($allPluginNames, true); $plugins = $input->getArgument('plugins'); if (empty($plugins)) { $plugins = $allPluginNames; $output->writeln("Going to build all plugins with Vue libraries: " . implode(', ', $plugins)); } else { $plugins = $this->filterPluginsWithoutVueLibrary($plugins); } if (empty($plugins)) { $output->writeln("No plugins to build!"); return self::FAILURE; } $plugins = $this->ensureUntranspiledPluginDependenciesArePresent($plugins); $plugins = PluginUmdAssetFetcher::orderPluginsByPluginDependencies($plugins); // remove webpack cache since it can result in strange builds if present Filesystem::unlinkRecursive(PIWIK_INCLUDE_PATH . '/node_modules/.cache', true); $bail = $input->getOption('bail'); $failed = $this->build($plugins, $printBuildCommand, $allPluginNames, $watch, $bail); return $failed > 0 ? self::FAILURE : self::SUCCESS; } private function ensureUntranspiledPluginDependenciesArePresent(array $plugins): array { $pluginDependenciesToAdd = []; foreach ($plugins as $plugin) { $dependencies = PluginUmdAssetFetcher::getPluginDependencies($plugin); foreach ($dependencies as $dependency) { if (!$this->isTypeOutputPresent($dependency)) { $pluginDependenciesToAdd[] = $dependency; } } } return array_unique(array_merge($plugins, $pluginDependenciesToAdd)); } private function isTypeOutputPresent(string $dependency): bool { $typeDirectory = PIWIK_INCLUDE_PATH . '/@types/' . $dependency . '/index.d.ts'; return is_file($typeDirectory); } private function build(array $plugins, bool $printBuildCommand, array $allPluginNames, bool $watch = false, bool $bail = false): int { if ($watch) { $this->watch($plugins, $printBuildCommand, $allPluginNames); return 0; } $failed = 0; foreach ($plugins as $plugin) { $buildFailed = (int) $this->buildFiles($plugin, $printBuildCommand, $allPluginNames); if ($buildFailed && $bail) { $this->getOutput()->writeln("Build failed, bailing."); return $failed; } $failed += $buildFailed; } return $failed; } private function watch(array $plugins, bool $printBuildCommand, array $allPluginNames): void { $commandSingle = 'cd ' . PIWIK_INCLUDE_PATH . ' && ' . "BROWSERSLIST_IGNORE_OLD_DATA=1 FORCE_COLOR=1 MATOMO_CURRENT_PLUGIN=%2\$s " . 'MATOMO_ALL_PLUGINS=' . implode(',', $allPluginNames) . ' ' . 'node ' . self::getVueCliServiceProxyBin() . ' build --mode=development --target lib --name ' . "%1\$s --filename=%1\$s.development --no-clean %2\$s/vue/src/index.ts --dest %2\$s/vue/dist --watch &"; $command = ''; foreach ($plugins as $plugin) { $pluginDirPath = Manager::getRelativePluginDirectory($plugin); $command .= sprintf($commandSingle, $plugin, $pluginDirPath) . ' '; } if ($printBuildCommand) { $this->getOutput()->writeln("$command"); return; } passthru($command); } private function buildFiles(string $plugin, bool $printBuildCommand, array $allPluginNames): int { $output = $this->getOutput(); $pluginDirPath = Manager::getRelativePluginDirectory($plugin); $command = 'cd ' . PIWIK_INCLUDE_PATH . ' && ' . "BROWSERSLIST_IGNORE_OLD_DATA=1 FORCE_COLOR=1 MATOMO_CURRENT_PLUGIN=$pluginDirPath " . 'MATOMO_ALL_PLUGINS=' . implode(',', $allPluginNames) . ' ' . 'node ' . self::getVueCliServiceProxyBin() . ' build --target lib --name ' . $plugin . " $pluginDirPath/vue/src/index.ts --dest $pluginDirPath/vue/dist"; if ($printBuildCommand) { $output->writeln("$command"); return 0; } $this->clearPluginTypes($plugin); $output->writeln("Building $plugin..."); if ($output->isVerbose()) { passthru($command, $returnCode); } else { $attempts = 0; while ($attempts < self::RETRY_COUNT) { exec($command, $cmdOutput, $returnCode); $concattedOutput = implode("\n", $cmdOutput); if ($this->isTypeScriptRaceConditionInOutput($plugin, $concattedOutput)) { $output->writeln("The TypeScript compiler encountered a race condition when compiling " . "files (files that exist were not found), retrying."); ++$attempts; continue; } if ( $returnCode != 0 || stripos($concattedOutput, 'warning') !== false ) { $output->writeln("Failed:\n"); $output->writeln($cmdOutput); $output->writeln(""); } break; } } @unlink("$pluginDirPath/vue/dist/$plugin.common.js"); @unlink("$pluginDirPath/vue/dist/$plugin.common.js.map"); @unlink("$pluginDirPath/vue/dist/demo.html"); // delete cjs webpack chunks shell_exec("rm " . "$pluginDirPath/vue/dist/$plugin.common.*.js* 2> /dev/null"); return $returnCode != 0; } private function filterPluginsWithoutVueLibrary(array $plugins, bool $isAll = false): array { $pluginsWithVue = []; $logger = StaticContainer::get(LoggerInterface::class); foreach ($plugins as $plugin) { $pluginDirPath = Manager::getPluginDirectory($plugin); $vueDir = $pluginDirPath . '/vue'; if (!is_dir($vueDir)) { if (!$isAll) { $logger->error("Cannot find vue library for plugin {plugin}, nothing to build.", ['plugin' => $plugin]); } continue; } $vueIndexFile = $vueDir . '/src/index.ts'; if (!is_file($vueIndexFile)) { $logger->warning("NOTE: Plugin {plugin} has a vue folder but no index.ts, cannot build it.", ['plugin' => $plugin]); continue; } $pluginsWithVue[] = $plugin; } return $pluginsWithVue; } public static function getVueCliServiceBin(): string { return PIWIK_INCLUDE_PATH . "/node_modules/@vue/cli-service/bin/vue-cli-service.js"; } public static function getVueCliServiceProxyBin(): string { return PIWIK_INCLUDE_PATH . "/plugins/CoreVue/scripts/cli-service-proxy.js"; } public static function checkVueCliServiceAvailable(): void { $vueCliBin = self::getVueCliServiceBin(); if (!is_file($vueCliBin)) { throw new \Exception("Cannot find vue cli bin file, did you forget to run `npm install`?"); } } private function clearWebpackCache(): void { $path = PIWIK_INCLUDE_PATH . '/node_modules/.cache'; Filesystem::unlinkRecursive($path, true); } private function clearPluginTypes($plugin): void { $path = PIWIK_INCLUDE_PATH . '/@types/' . $plugin; Filesystem::unlinkRecursive($path, true); } private function checkNodeJsVersion(): void { $output = $this->getOutput(); $nodeVersion = ltrim(trim(`node -v`), 'v'); $npmVersion = ltrim(trim(`npm -v`), 'v'); if (version_compare($nodeVersion, self::RECOMMENDED_NODE_VERSION, '<')) { $output->writeln(sprintf( "The recommended node version for working with Vue is version %s or " . "greater and it looks like you're using %s. Building Vue files may not work with an older version, so " . "we recommend upgrading. nvm can be used to easily install new node versions.", self::RECOMMENDED_NODE_VERSION, $nodeVersion )); } if (version_compare($npmVersion, self::RECOMMENDED_NPM_VERSION, '<')) { $output->writeln(sprintf( "The recommended npm version for working with Vue is version %s " . "or greater and it looks like you're using %s. Using an older version may result in improper " . "dependencies being used, so we recommend upgrading. You can upgrade to the latest version with the " . "command %s", self::RECOMMENDED_NPM_VERSION, $npmVersion, 'npm install -g npm@latest' )); } } private function isTypeScriptRaceConditionInOutput(string $plugin, string $concattedOutput): bool { // Console output can contain ANSI escape sequences; remove them first to make regex matching stable. // Example: // "\x1b[41m ERROR \x1b[49m ... TS2307: Cannot find module ..." // becomes: // " ERROR ... TS2307: Cannot find module ..." $normalizedOutput = preg_replace('/\x1b\[[0-9;]*m/', '', $concattedOutput); if (empty($normalizedOutput)) { return false; } // Match TS2307 blocks and capture: // 1) the importer file path // 2) the missing module path // so we can verify whether the target actually exists on disk. $matches = []; preg_match_all( '/in\s+([^\n]+)\(\d+,\d+\)\s*\n\s*TS2307:\s+Cannot find module \'([^\']+)\' or its corresponding type declarations\./m', $normalizedOutput, $matches, PREG_SET_ORDER ); foreach ($matches as $match) { $importerFile = trim($match[1]); $missingModule = $match[2]; if ($this->isRelativeImport($missingModule)) { // Relative imports are resolved from the importer directory. // If the path exists, this is likely a transient TS resolver race and should be retried. $resolvedPath = dirname($importerFile) . DIRECTORY_SEPARATOR . $missingModule; if (file_exists($resolvedPath)) { return true; } continue; } // Keep legacy behavior for non-relative plugin imports (e.g. "SomePluginComponent"). $filePath = Manager::getPluginDirectory($plugin) . '/vue/src/' . $missingModule; if (file_exists($filePath)) { return true; } } return false; } private function isRelativeImport(string $importPath): bool { return strpos($importPath, './') === 0 || strpos($importPath, '../') === 0; } private function getAllPlugins(): array { $pluginDirectories = array_merge( $GLOBALS['MATOMO_PLUGIN_DIRS'] ?? [], [ [ 'pluginsPathAbsolute' => PIWIK_INCLUDE_PATH . '/plugins', 'webrootDirRelativeToMatomo' => '.', ], ] ); $allPlugins = []; foreach ($pluginDirectories as $pluginDirectoryInfo) { $absolutePath = $pluginDirectoryInfo['pluginsPathAbsolute']; foreach (scandir($absolutePath) as $subdirectory) { $wholePath = $absolutePath . DIRECTORY_SEPARATOR . $subdirectory; if ( is_dir($wholePath) && $subdirectory !== '.' && $subdirectory !== '..' ) { $allPlugins[] = $subdirectory; } } } return $allPlugins; } }