diff --git a/module/VuFindConsole/Module.php b/module/VuFindConsole/Module.php index 25daa33b94d9ddc168f015b13f7504673d851d9c..0f0f8063939aa0bbc7cf897c6c12473c0ead01f0 100644 --- a/module/VuFindConsole/Module.php +++ b/module/VuFindConsole/Module.php @@ -96,6 +96,7 @@ class Module implements \Zend\ModuleManager\Feature\ConsoleUsageProviderInterfac public function getConsoleUsage(Console $console) { return [ + 'compile theme' => 'Flatten a theme hierarchy for improved performance', 'generate dynamicroute' => 'Add a dynamic route', 'generate extendservice' => 'Override a service with a new child class', 'generate nontabrecordaction' => 'Add routes for non-tab record action', diff --git a/module/VuFindConsole/config/module.config.php b/module/VuFindConsole/config/module.config.php index d02890ccae53b1286cc29ab6104d472f8d10458b..022984b6d9c211d55683f91220a4ac5dd7a9ac5e 100644 --- a/module/VuFindConsole/config/module.config.php +++ b/module/VuFindConsole/config/module.config.php @@ -4,6 +4,7 @@ namespace VuFindConsole\Module\Configuration; $config = [ 'controllers' => [ 'factories' => [ + 'compile' => 'VuFindConsole\Controller\Factory::getCompileController', 'generate' => 'VuFindConsole\Controller\Factory::getGenerateController', 'harvest' => 'VuFindConsole\Controller\Factory::getHarvestController', 'import' => 'VuFindConsole\Controller\Factory::getImportController', @@ -35,6 +36,7 @@ $config = [ ]; $routes = [ + 'compile/theme' => 'compile theme [--force] [<source>] [<target>]', 'generate/dynamicroute' => 'generate dynamicroute [<name>] [<newController>] [<newAction>] [<module>]', 'generate/extendservice' => 'generate extendservice [<source>] [<target>]', 'generate/nontabrecordaction' => 'generate nontabrecordaction [<newAction>] [<module>]', diff --git a/module/VuFindConsole/src/VuFindConsole/Controller/CompileController.php b/module/VuFindConsole/src/VuFindConsole/Controller/CompileController.php new file mode 100644 index 0000000000000000000000000000000000000000..4836a632f2fea2b76d4a85fc39ebb3019a86bea4 --- /dev/null +++ b/module/VuFindConsole/src/VuFindConsole/Controller/CompileController.php @@ -0,0 +1,78 @@ +<?php +/** + * Compile Controller Module + * + * PHP version 5 + * + * Copyright (C) Villanova University 2017. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + * @category VuFind + * @package Controller + * @author Demian Katz <demian.katz@villanova.edu> + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:plugins:controllers Wiki + */ +namespace VuFindConsole\Controller; +use Zend\Console\Console; + +/** + * This controller handles the command-line tool for compiling themes. + * + * @category VuFind + * @package Controller + * @author Demian Katz <demian.katz@villanova.edu> + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:plugins:controllers Wiki + */ +class CompileController extends AbstractBase +{ + /** + * Compile theme action. + * + * @return mixed + */ + public function themeAction() + { + $request = $this->getRequest(); + $source = $request->getParam('source'); + if (empty($source)) { + Console::writeLine( + 'Usage: ' . $request->getScriptName() + . ' compile theme [--force] SOURCE [TARGET]' + ); + Console::writeLine("\tSOURCE - the source theme to compile (required)"); + Console::writeLine( + "\tTARGET - the target name for the compiled theme " + . '(optional; defaults to SOURCE_compiled)' + ); + Console::writeLine( + "(If TARGET exists, it will only be overwritten when --force is set)" + ); + return $this->getFailureResponse(); + } + $target = $request->getParam('target'); + if (empty($target)) { + $target = "{$source}_compiled"; + } + $compiler = $this->serviceLocator->get('VuFindTheme\ThemeCompiler'); + if (!$compiler->compile($source, $target, $request->getParam('force'))) { + Console::writeLine($compiler->getLastError()); + return $this->getFailureResponse(); + } + Console::writeLine('Success.'); + return $this->getSuccessResponse(); + } +} diff --git a/module/VuFindTheme/Module.php b/module/VuFindTheme/Module.php index 55f5d0a4952e441f56d624833e0e4c54db352eaf..977480df653f0dea3d0c564bb50eabcb682ccb24 100644 --- a/module/VuFindTheme/Module.php +++ b/module/VuFindTheme/Module.php @@ -26,6 +26,7 @@ * @link https://vufind.org/wiki/development */ namespace VuFindTheme; +use Zend\ServiceManager\ServiceManager; /** * ZF2 module definition for the VuFind theme system. @@ -63,6 +64,8 @@ class Module { return [ 'factories' => [ + 'VuFindTheme\ThemeCompiler' => + 'VuFindTheme\Module::getThemeCompiler', 'VuFindTheme\ThemeInfo' => 'VuFindTheme\Module::getThemeInfo', ], 'invokables' => [ @@ -93,6 +96,18 @@ class Module ]; } + /** + * Factory function for ThemeCompiler object. + * + * @param ServiceManager $sm Service manager + * + * @return ThemeCompiler + */ + public static function getThemeCompiler(ServiceManager $sm) + { + return new ThemeCompiler($sm->get('VuFindTheme\ThemeInfo')); + } + /** * Factory function for ThemeInfo object. * diff --git a/module/VuFindTheme/src/VuFindTheme/ThemeCompiler.php b/module/VuFindTheme/src/VuFindTheme/ThemeCompiler.php new file mode 100644 index 0000000000000000000000000000000000000000..55ceed5fdcb7896bf48b5543b815ee0fb8fdf74c --- /dev/null +++ b/module/VuFindTheme/src/VuFindTheme/ThemeCompiler.php @@ -0,0 +1,249 @@ +<?php +/** + * Class to compile a theme hierarchy into a single flat theme. + * + * PHP version 5 + * + * Copyright (C) Villanova University 2017. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + * @category VuFind + * @package Theme + * @author Demian Katz <demian.katz@villanova.edu> + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Site + */ +namespace VuFindTheme; + +/** + * Class to compile a theme hierarchy into a single flat theme. + * + * @category VuFind + * @package Theme + * @author Demian Katz <demian.katz@villanova.edu> + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Site + */ +class ThemeCompiler +{ + /** + * Theme info object + * + * @var ThemeInfo + */ + protected $info; + + /** + * Last error message + * + * @var string + */ + protected $lastError = null; + + /** + * Constructor + * + * @param ThemeInfo $info Theme info object + */ + public function __construct(ThemeInfo $info) + { + $this->info = $info; + } + + /** + * Compile from $source theme into $target theme. + * + * @param string $source Name of source theme + * @param string $target Name of target theme + * @param bool $forceOverwrite Should we overwrite the target if it exists? + * + * @return bool + */ + public function compile($source, $target, $forceOverwrite = false) + { + // Validate input: + try { + $this->info->setTheme($source); + } catch (\Exception $ex) { + return $this->setLastError($ex->getMessage()); + } + // Validate output: + $baseDir = $this->info->getBaseDir(); + $targetDir = "$baseDir/$target"; + if (file_exists($targetDir)) { + if (!$forceOverwrite) { + return $this->setLastError( + 'Cannot overwrite ' . $targetDir . ' without --force switch!' + ); + } + if (!$this->deleteDir($targetDir)) { + return false; + } + } + if (!mkdir($targetDir)) { + return $this->setLastError("Cannot create $targetDir"); + } + + // Copy all the files, relying on the fact that the output of getThemeInfo + // includes the entire theme inheritance chain in the appropriate order: + $info = $this->info->getThemeInfo(); + $config = []; + foreach ($info as $source => $currentConfig) { + $config = $this->mergeConfig($currentConfig, $config); + if (!$this->copyDir("$baseDir/$source", $targetDir)) { + return false; + } + } + $configFile = "$targetDir/theme.config.php"; + $configContents = '<?php return ' . var_export($config, true) . ';'; + if (!file_put_contents($configFile, $configContents)) { + return $this->setLastError("Problem exporting $configFile."); + } + return true; + } + + /** + * Get last error message. + * + * @return string + */ + public function getLastError() + { + return $this->lastError; + } + + /** + * Remove a theme directory (used for cleanup in testing). + * + * @param string $theme Name of theme to remove. + * + * @return bool + */ + public function removeTheme($theme) + { + return $this->deleteDir($this->info->getBaseDir() . '/' . $theme); + } + + /** + * Copy the contents of $src into $dest if no matching files already exist. + * + * @param string $src Source directory + * @param string $dest Target directory + * + * @return bool + */ + protected function copyDir($src, $dest) + { + if (!is_dir($dest)) { + if (!mkdir($dest)) { + return $this->setLastError("Cannot create $dest"); + } + } + $dir = opendir($src); + while ($current = readdir($dir)) { + if ($current === '.' || $current === '..') { + continue; + } + if (is_dir("$src/$current")) { + if (!$this->copyDir("$src/$current", "$dest/$current")) { + return false; + } + } else if (!file_exists("$dest/$current") + && !copy("$src/$current", "$dest/$current") + ) { + return $this->setLastError( + "Cannot copy $src/$current to $dest/$current." + ); + } + } + closedir($dir); + return true; + } + + /** + * Recursively delete a directory and its contents. + * + * @param string $path Directory to delete. + * + * @return bool + */ + protected function deleteDir($path) + { + $dir = opendir($path); + while ($current = readdir($dir)) { + if ($current === '.' || $current === '..') { + continue; + } + if (is_dir("$path/$current")) { + if (!$this->deleteDir("$path/$current")) { + return false; + } + } else if (!unlink("$path/$current")) { + return $this->setLastError("Cannot delete $path/$current"); + } + } + closedir($dir); + return rmdir($path); + } + + /** + * Merge configurations from $src into $dest; return the result. + * + * @param array $src Source configuration + * @param array $dest Destination configuration + * + * @return array + */ + protected function mergeConfig($src, $dest) + { + foreach ($src as $key => $value) { + switch ($key) { + case 'extends': + // always set "extends" to false; we're flattening, after all! + $dest[$key] = false; + break; + case 'helpers': + // Call this function recursively to deal with the helpers + // sub-array: + $dest[$key] = $this + ->mergeConfig($value, isset($dest[$key]) ? $dest[$key] : []); + break; + default: + // Default behavior: merge arrays, let existing flat settings + // trump new incoming ones: + if (!isset($dest[$key])) { + $dest[$key] = $value; + } else if (is_array($dest[$key])) { + $dest[$key] = array_merge($value, $dest[$key]); + } + break; + } + } + return $dest; + } + + /** + * Set last error message and return a boolean false. + * + * @param string $error Error message. + * + * @return bool + */ + protected function setLastError($error) + { + $this->lastError = $error; + return false; + } +} diff --git a/module/VuFindTheme/tests/unit-tests/fixtures/themes/child/css/child.css b/module/VuFindTheme/tests/unit-tests/fixtures/themes/child/css/child.css new file mode 100644 index 0000000000000000000000000000000000000000..05d671dc3fde1d9aeb0e781d20837fa620b6e233 --- /dev/null +++ b/module/VuFindTheme/tests/unit-tests/fixtures/themes/child/css/child.css @@ -0,0 +1 @@ +p {} \ No newline at end of file diff --git a/module/VuFindTheme/tests/unit-tests/fixtures/themes/child/js/extra.js b/module/VuFindTheme/tests/unit-tests/fixtures/themes/child/js/extra.js new file mode 100644 index 0000000000000000000000000000000000000000..acf0e79bbbcbe736a588be2700f4ad141e1f9430 --- /dev/null +++ b/module/VuFindTheme/tests/unit-tests/fixtures/themes/child/js/extra.js @@ -0,0 +1 @@ +// no code \ No newline at end of file diff --git a/module/VuFindTheme/tests/unit-tests/fixtures/themes/child/js/hello.js b/module/VuFindTheme/tests/unit-tests/fixtures/themes/child/js/hello.js new file mode 100644 index 0000000000000000000000000000000000000000..5643b9bb3956caef9c25078c9c38ef453246a5bc --- /dev/null +++ b/module/VuFindTheme/tests/unit-tests/fixtures/themes/child/js/hello.js @@ -0,0 +1 @@ +alert('hello child'); \ No newline at end of file diff --git a/module/VuFindTheme/tests/unit-tests/fixtures/themes/child/theme.config.php b/module/VuFindTheme/tests/unit-tests/fixtures/themes/child/theme.config.php index 64db8df45e841be37055fe2635ccfcd38b844cde..d79f66278bdf43e41dc3c1382f48b1b511776fc0 100644 --- a/module/VuFindTheme/tests/unit-tests/fixtures/themes/child/theme.config.php +++ b/module/VuFindTheme/tests/unit-tests/fixtures/themes/child/theme.config.php @@ -1,4 +1,14 @@ <?php return [ 'extends' => 'parent', + 'css' => ['child.css'], + 'js' => ['extra.js'], + 'helpers' => [ + 'factories' => [ + 'foo' => 'fooOverrideFactory', + ], + 'invokables' => [ + 'xyzzy' => 'Xyzzy', + ] + ], ]; diff --git a/module/VuFindTheme/tests/unit-tests/fixtures/themes/parent/js/hello.js b/module/VuFindTheme/tests/unit-tests/fixtures/themes/parent/js/hello.js new file mode 100644 index 0000000000000000000000000000000000000000..58376488de958511531932ebbff86bec968e0340 --- /dev/null +++ b/module/VuFindTheme/tests/unit-tests/fixtures/themes/parent/js/hello.js @@ -0,0 +1 @@ +alert('hello parent'); \ No newline at end of file diff --git a/module/VuFindTheme/tests/unit-tests/fixtures/themes/parent/theme.config.php b/module/VuFindTheme/tests/unit-tests/fixtures/themes/parent/theme.config.php index 94c5760c4c7940685658d38da1308b0e8fbb6a8f..4a5c3b71914938ed0f50dec693e19aa4486866db 100644 --- a/module/VuFindTheme/tests/unit-tests/fixtures/themes/parent/theme.config.php +++ b/module/VuFindTheme/tests/unit-tests/fixtures/themes/parent/theme.config.php @@ -1,4 +1,11 @@ <?php return [ 'extends' => false, + 'js' => ['hello.js'], + 'helpers' => [ + 'factories' => [ + 'foo' => 'fooFactory', + 'bar' => 'barFactory', + ] + ], ]; diff --git a/module/VuFindTheme/tests/unit-tests/src/VuFindTest/ThemeCompilerTest.php b/module/VuFindTheme/tests/unit-tests/src/VuFindTest/ThemeCompilerTest.php new file mode 100644 index 0000000000000000000000000000000000000000..6640bd8271afc3b612d1764d0e9840d442cfb006 --- /dev/null +++ b/module/VuFindTheme/tests/unit-tests/src/VuFindTest/ThemeCompilerTest.php @@ -0,0 +1,189 @@ +<?php +/** + * ThemeCompiler Test Class + * + * PHP version 5 + * + * Copyright (C) Villanova University 2017. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + * @category VuFind + * @package Tests + * @author Demian Katz <demian.katz@villanova.edu> + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:testing:unit_tests Wiki + */ +namespace VuFindTest; +use VuFindTheme\ThemeCompiler; +use VuFindTheme\ThemeInfo; + +/** + * ThemeCompiler Test Class + * + * @category VuFind + * @package Tests + * @author Demian Katz <demian.katz@villanova.edu> + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:testing:unit_tests Wiki + */ +class ThemeCompilerTest extends Unit\TestCase +{ + /** + * Path to theme fixtures + * + * @var string + */ + protected $fixturePath; + + /** + * ThemeInfo object for tests + * + * @var ThemeInfo + */ + protected $info; + + /** + * Path where new theme will be created + * + * @var string + */ + protected $targetPath; + + /** + * Constructor + */ + public function __construct() + { + $this->fixturePath = realpath(__DIR__ . '/../../fixtures/themes'); + $this->info = new ThemeInfo($this->fixturePath, 'parent'); + $this->targetPath = $this->info->getBaseDir() . '/compiled'; + } + + /** + * Standard setup method. + * + * @return void + */ + public function setUp() + { + // Give up if the target directory already exists: + if (is_dir($this->targetPath)) { + return $this->markTestSkipped('compiled theme already exists.'); + } + } + + /** + * Test the compiler. + * + * @return void + */ + public function testStandardCompilation() + { + $baseDir = $this->info->getBaseDir(); + $parentDir = $baseDir . '/parent'; + $childDir = $baseDir . '/child'; + $compiler = $this->getThemeCompiler(); + $result = $compiler->compile('child', 'compiled'); + + // Did the compiler report success? + $this->assertEquals('', $compiler->getLastError()); + $this->assertTrue($result); + + // Was the target directory created with the expected files? + $this->assertTrue(is_dir($this->targetPath)); + $this->assertTrue(file_exists("{$this->targetPath}/parent.txt")); + $this->assertTrue(file_exists("{$this->targetPath}/child.txt")); + + // Did the right version of the file that exists in both parent and child + // get copied over? + $this->assertEquals( + file_get_contents("$childDir/js/hello.js"), + file_get_contents("{$this->targetPath}/js/hello.js") + ); + $this->assertNotEquals( + file_get_contents("$parentDir/js/hello.js"), + file_get_contents("{$this->targetPath}/js/hello.js") + ); + + // Did the configuration merge correctly? + $expectedConfig = [ + 'extends' => false, + 'css' => ['child.css'], + 'js' => ['hello.js', 'extra.js'], + 'helpers' => [ + 'factories' => [ + 'foo' => 'fooOverrideFactory', + 'bar' => 'barFactory', + ], + 'invokables' => [ + 'xyzzy' => 'Xyzzy', + ] + ], + ]; + $mergedConfig = include "{$this->targetPath}/theme.config.php"; + $this->assertEquals($expectedConfig, $mergedConfig); + } + + /** + * Test overwrite protection. + * + * @return void + */ + public function testOverwriteProtection() + { + // First, compile the theme: + $compiler = $this->getThemeCompiler(); + $this->assertTrue($compiler->compile('child', 'compiled')); + + // Now confirm that by default, we're not allowed to recompile it on + // top of itself... + $this->assertFalse($compiler->compile('child', 'compiled')); + $this->assertEquals( + "Cannot overwrite {$this->targetPath} without --force switch!", + $compiler->getLastError() + ); + + // Write a file into the compiled theme so we can check that it gets + // removed when we force a recompile: + $markerFile = $this->targetPath . '/fake-marker.txt'; + file_put_contents($markerFile, 'junk'); + $this->assertTrue(file_exists($markerFile)); + + // Now recompile with "force" set to true, confirm that this succeeds, + // and make sure the marker file is now gone: + $this->assertTrue($compiler->compile('child', 'compiled', true)); + $this->assertFalse(file_exists($markerFile)); + } + + /** + * Teardown method: clean up test directory. + * + * @return void + */ + public function tearDown() + { + $this->getThemeCompiler()->removeTheme('compiled'); + } + + /** + * Get a test ThemeCompiler object + * + * @return ThemeInfo + */ + protected function getThemeCompiler() + { + return new ThemeCompiler($this->info); + } +}