From a8fc92f473f0eb8a51ca5c5c3e6fe16a6d1749c1 Mon Sep 17 00:00:00 2001
From: Chris Hallberg <crhallberg@gmail.com>
Date: Wed, 27 May 2020 13:34:01 -0400
Subject: [PATCH] SCSS PHP compiler (#1538)

---
 composer.json                                 |   5 +-
 composer.lock                                 | 177 +++++++++++---
 .../VuFindConsole/Command/PluginManager.php   |   2 +
 .../Util/AbstractCssBuilderCommand.php        | 114 +++++++++
 .../Command/Util/CssBuilderCommand.php        |  58 +----
 .../Command/Util/ScssBuilderCommand.php       |  70 ++++++
 .../Util/ScssBuilderCommandFactory.php        |  65 ++++++
 .../Command/Util/ScssBuilderCommandTest.php   |  69 ++++++
 .../VuFindTheme/AbstractCssPreCompiler.php    | 220 ++++++++++++++++++
 .../src/VuFindTheme/LessCompiler.php          | 165 +------------
 .../src/VuFindTheme/ScssCompiler.php          | 108 +++++++++
 ...ompilerTest.php => CssPreCompilerTest.php} | 135 +++++++----
 12 files changed, 891 insertions(+), 297 deletions(-)
 create mode 100644 module/VuFindConsole/src/VuFindConsole/Command/Util/AbstractCssBuilderCommand.php
 create mode 100644 module/VuFindConsole/src/VuFindConsole/Command/Util/ScssBuilderCommand.php
 create mode 100644 module/VuFindConsole/src/VuFindConsole/Command/Util/ScssBuilderCommandFactory.php
 create mode 100644 module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/ScssBuilderCommandTest.php
 create mode 100644 module/VuFindTheme/src/VuFindTheme/AbstractCssPreCompiler.php
 create mode 100644 module/VuFindTheme/src/VuFindTheme/ScssCompiler.php
 rename module/VuFindTheme/tests/unit-tests/src/VuFindTest/{LessCompilerTest.php => CssPreCompilerTest.php} (63%)

diff --git a/composer.json b/composer.json
index 687766b6fd1..d34d5be8db8 100644
--- a/composer.json
+++ b/composer.json
@@ -52,6 +52,7 @@
         "laminas/laminas-text": "2.7.1",
         "laminas/laminas-validator": "2.13.0",
         "laminas/laminas-view": "2.11.2",
+        "league/commonmark": "1.4.0",
         "matthiasmullie/minify": "1.3.62",
         "misd/linkify": "1.1.4",
         "ocramius/proxy-manager": "2.1.1",
@@ -61,6 +62,7 @@
         "pear/validate_ispn": "dev-master",
         "phing/phing": "2.16.3",
         "ppito/laminas-whoops": "2.0.0",
+        "scssphp/scssphp": "1.1.0",
         "serialssolutions/summon": "1.3.0",
         "symfony/console": "4.4.4",
         "symfony/yaml": "3.4.36",
@@ -74,8 +76,7 @@
         "yajra/laravel-pdo-via-oci8": "2.1.1",
         "zendframework/zendrest": "2.0.2",
         "zendframework/zendservice-amazon": "2.3.1",
-        "zf-commons/zfc-rbac": "2.6.3",
-        "league/commonmark": "1.4.0"
+        "zf-commons/zfc-rbac": "2.6.3"
     },
     "require-dev": {
         "behat/mink": "1.7.1",
diff --git a/composer.lock b/composer.lock
index a0979242950..803a4d74424 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "2fa106f50dfdef1a435e63ce5689d508",
+    "content-hash": "9919518ab3dc824de4962304a832b927",
     "packages": [
         {
             "name": "ahand/mobileesp",
@@ -937,16 +937,16 @@
         },
         {
             "name": "laminas/laminas-dependency-plugin",
-            "version": "1.0.3",
+            "version": "1.0.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/laminas/laminas-dependency-plugin.git",
-                "reference": "f269716dc584cd7b69e7f6e8ac1092d645ab56d5"
+                "reference": "38bf91861f5b4d49f9a1c530327c997f7a7fb2db"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/laminas/laminas-dependency-plugin/zipball/f269716dc584cd7b69e7f6e8ac1092d645ab56d5",
-                "reference": "f269716dc584cd7b69e7f6e8ac1092d645ab56d5",
+                "url": "https://api.github.com/repos/laminas/laminas-dependency-plugin/zipball/38bf91861f5b4d49f9a1c530327c997f7a7fb2db",
+                "reference": "38bf91861f5b4d49f9a1c530327c997f7a7fb2db",
                 "shasum": ""
             },
             "require": {
@@ -979,7 +979,13 @@
                 "BSD-3-Clause"
             ],
             "description": "Replace zendframework and zfcampus packages with their Laminas Project equivalents.",
-            "time": "2020-01-14T19:36:52+00:00"
+            "funding": [
+                {
+                    "url": "https://funding.communitybridge.org/projects/laminas-project",
+                    "type": "community_bridge"
+                }
+            ],
+            "time": "2020-05-20T13:45:39+00:00"
         },
         {
             "name": "laminas/laminas-dom",
@@ -3161,6 +3167,12 @@
                 "laminas",
                 "zf"
             ],
+            "funding": [
+                {
+                    "url": "https://funding.communitybridge.org/projects/laminas-project",
+                    "type": "community_bridge"
+                }
+            ],
             "time": "2020-04-03T16:01:00+00:00"
         },
         {
@@ -3235,6 +3247,32 @@
                 "md",
                 "parser"
             ],
+            "funding": [
+                {
+                    "url": "https://enjoy.gitstore.app/repositories/thephpleague/commonmark",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://www.colinodell.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://www.paypal.me/colinpodell/10.00",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/colinodell",
+                    "type": "github"
+                },
+                {
+                    "url": "https://www.patreon.com/colinodell",
+                    "type": "patreon"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/league/commonmark",
+                    "type": "tidelift"
+                }
+            ],
             "time": "2020-04-18T20:46:13+00:00"
         },
         {
@@ -4437,6 +4475,67 @@
             ],
             "time": "2017-10-23T01:57:42+00:00"
         },
+        {
+            "name": "scssphp/scssphp",
+            "version": "1.1.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/scssphp/scssphp.git",
+                "reference": "4363ddce8d750f055c436833dd77d83517946532"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/scssphp/scssphp/zipball/4363ddce8d750f055c436833dd77d83517946532",
+                "reference": "4363ddce8d750f055c436833dd77d83517946532",
+                "shasum": ""
+            },
+            "require": {
+                "ext-ctype": "*",
+                "ext-json": "*",
+                "php": ">=5.6.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^5.7 || ^6.5 || ^7.5 || ^8.3",
+                "squizlabs/php_codesniffer": "~3.5",
+                "twbs/bootstrap": "~4.3",
+                "zurb/foundation": "~6.5"
+            },
+            "bin": [
+                "bin/pscss"
+            ],
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "ScssPhp\\ScssPhp\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Anthon Pang",
+                    "email": "apang@softwaredevelopment.ca",
+                    "homepage": "https://github.com/robocoder"
+                },
+                {
+                    "name": "Cédric Morin",
+                    "email": "cedric@yterium.com",
+                    "homepage": "https://github.com/Cerdic"
+                }
+            ],
+            "description": "scssphp is a compiler for SCSS written in PHP.",
+            "homepage": "http://scssphp.github.io/scssphp/",
+            "keywords": [
+                "css",
+                "less",
+                "sass",
+                "scss",
+                "stylesheet"
+            ],
+            "time": "2020-04-21T15:53:32+00:00"
+        },
         {
             "name": "serialssolutions/summon",
             "version": "v1.3.0",
@@ -4745,16 +4844,16 @@
         },
         {
             "name": "symfony/polyfill-ctype",
-            "version": "v1.16.0",
+            "version": "v1.17.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-ctype.git",
-                "reference": "1aab00e39cebaef4d8652497f46c15c1b7e45294"
+                "reference": "e94c8b1bbe2bc77507a1056cdb06451c75b427f9"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/1aab00e39cebaef4d8652497f46c15c1b7e45294",
-                "reference": "1aab00e39cebaef4d8652497f46c15c1b7e45294",
+                "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/e94c8b1bbe2bc77507a1056cdb06451c75b427f9",
+                "reference": "e94c8b1bbe2bc77507a1056cdb06451c75b427f9",
                 "shasum": ""
             },
             "require": {
@@ -4766,7 +4865,7 @@
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.16-dev"
+                    "dev-master": "1.17-dev"
                 }
             },
             "autoload": {
@@ -4813,20 +4912,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-05-08T16:50:20+00:00"
+            "time": "2020-05-12T16:14:59+00:00"
         },
         {
             "name": "symfony/polyfill-mbstring",
-            "version": "v1.16.0",
+            "version": "v1.17.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-mbstring.git",
-                "reference": "a54881ec0ab3b2005c406aed0023c062879031e7"
+                "reference": "fa79b11539418b02fc5e1897267673ba2c19419c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/a54881ec0ab3b2005c406aed0023c062879031e7",
-                "reference": "a54881ec0ab3b2005c406aed0023c062879031e7",
+                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/fa79b11539418b02fc5e1897267673ba2c19419c",
+                "reference": "fa79b11539418b02fc5e1897267673ba2c19419c",
                 "shasum": ""
             },
             "require": {
@@ -4838,7 +4937,7 @@
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.16-dev"
+                    "dev-master": "1.17-dev"
                 }
             },
             "autoload": {
@@ -4886,20 +4985,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-05-08T16:50:20+00:00"
+            "time": "2020-05-12T16:47:27+00:00"
         },
         {
             "name": "symfony/polyfill-php73",
-            "version": "v1.16.0",
+            "version": "v1.17.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-php73.git",
-                "reference": "7e95fe59d12169fcf4041487e4bf34fca37ee0ed"
+                "reference": "a760d8964ff79ab9bf057613a5808284ec852ccc"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/7e95fe59d12169fcf4041487e4bf34fca37ee0ed",
-                "reference": "7e95fe59d12169fcf4041487e4bf34fca37ee0ed",
+                "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/a760d8964ff79ab9bf057613a5808284ec852ccc",
+                "reference": "a760d8964ff79ab9bf057613a5808284ec852ccc",
                 "shasum": ""
             },
             "require": {
@@ -4908,7 +5007,7 @@
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.16-dev"
+                    "dev-master": "1.17-dev"
                 }
             },
             "autoload": {
@@ -4958,7 +5057,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-05-02T14:56:09+00:00"
+            "time": "2020-05-12T16:47:27+00:00"
         },
         {
             "name": "symfony/property-access",
@@ -6009,6 +6108,12 @@
                 "Xdebug",
                 "performance"
             ],
+            "funding": [
+                {
+                    "url": "https://packagist.com",
+                    "type": "custom"
+                }
+            ],
             "time": "2020-03-01T12:26:26+00:00"
         },
         {
@@ -8580,16 +8685,16 @@
         },
         {
             "name": "symfony/polyfill-php70",
-            "version": "v1.16.0",
+            "version": "v1.17.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-php70.git",
-                "reference": "6cc55bd2a085dbe05b4122c1987a82897b8da419"
+                "reference": "82225c2d7d23d7e70515496d249c0152679b468e"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-php70/zipball/6cc55bd2a085dbe05b4122c1987a82897b8da419",
-                "reference": "6cc55bd2a085dbe05b4122c1987a82897b8da419",
+                "url": "https://api.github.com/repos/symfony/polyfill-php70/zipball/82225c2d7d23d7e70515496d249c0152679b468e",
+                "reference": "82225c2d7d23d7e70515496d249c0152679b468e",
                 "shasum": ""
             },
             "require": {
@@ -8599,7 +8704,7 @@
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.16-dev"
+                    "dev-master": "1.17-dev"
                 }
             },
             "autoload": {
@@ -8649,20 +8754,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-05-02T14:56:09+00:00"
+            "time": "2020-05-12T16:47:27+00:00"
         },
         {
             "name": "symfony/polyfill-php72",
-            "version": "v1.16.0",
+            "version": "v1.17.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-php72.git",
-                "reference": "42fda6d7380e5c940d7f68341ccae89d5ab9963b"
+                "reference": "f048e612a3905f34931127360bdd2def19a5e582"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/42fda6d7380e5c940d7f68341ccae89d5ab9963b",
-                "reference": "42fda6d7380e5c940d7f68341ccae89d5ab9963b",
+                "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/f048e612a3905f34931127360bdd2def19a5e582",
+                "reference": "f048e612a3905f34931127360bdd2def19a5e582",
                 "shasum": ""
             },
             "require": {
@@ -8671,7 +8776,7 @@
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.16-dev"
+                    "dev-master": "1.17-dev"
                 }
             },
             "autoload": {
@@ -8718,7 +8823,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-05-08T17:28:34+00:00"
+            "time": "2020-05-12T16:47:27+00:00"
         },
         {
             "name": "symfony/process",
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/PluginManager.php b/module/VuFindConsole/src/VuFindConsole/Command/PluginManager.php
index 998130e7544..b1222e7fab0 100644
--- a/module/VuFindConsole/src/VuFindConsole/Command/PluginManager.php
+++ b/module/VuFindConsole/src/VuFindConsole/Command/PluginManager.php
@@ -80,6 +80,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager
         'util/index_reserves' => Util\IndexReservesCommand::class,
         'util/lint_marc' => Util\LintMarcCommand::class,
         'util/optimize' => Util\OptimizeCommand::class,
+        'util/scssBuilder' => Util\ScssBuilderCommand::class,
         'util/sitemap' => Util\SitemapCommand::class,
         'util/suppressed' => Util\SuppressedCommand::class,
         'util/switch_db_hash' => Util\SwitchDbHashCommand::class,
@@ -142,6 +143,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager
             Util\AbstractSolrAndIlsCommandFactory::class,
         Util\LintMarcCommand::class => InvokableFactory::class,
         Util\OptimizeCommand::class => Util\AbstractSolrCommandFactory::class,
+        Util\ScssBuilderCommand::class => Util\ScssBuilderCommandFactory::class,
         Util\SitemapCommand::class => Util\SitemapCommandFactory::class,
         Util\SuppressedCommand::class =>
             Util\AbstractSolrAndIlsCommandFactory::class,
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/AbstractCssBuilderCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/AbstractCssBuilderCommand.php
new file mode 100644
index 00000000000..876c9535197
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/AbstractCssBuilderCommand.php
@@ -0,0 +1,114 @@
+<?php
+/**
+ * Abstract console command: build CSS with precompiler.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * 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  Console
+ * @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 Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * Abstract console command: build CSS with precompiler.
+ *
+ * @category VuFind
+ * @package  Console
+ * @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 Wiki
+ */
+abstract class AbstractCssBuilderCommand extends Command
+{
+    /**
+     * Cache directory for compiler
+     *
+     * @var string
+     */
+    protected $cacheDir;
+
+    /**
+     * Name of precompiler format
+     *
+     * @var string
+     */
+    protected $format;
+
+    /**
+     * Constructor
+     *
+     * @param string      $cacheDir Cache directory for compiler
+     * @param string|null $name     The name of the command; passing null means it
+     * must be set in configure()
+     */
+    public function __construct($cacheDir, $name = null)
+    {
+        $this->cacheDir = $cacheDir;
+        parent::__construct($name);
+    }
+
+    /**
+     * Configure the command.
+     *
+     * @return void
+     */
+    protected function configure()
+    {
+        $this
+            ->setDescription($this->format . ' compiler')
+            ->setHelp('Compiles CSS files from ' . $this->format . '.')
+            ->addArgument(
+                'themes',
+                InputArgument::IS_ARRAY | InputArgument::OPTIONAL,
+                'Name of theme(s) to compile; omit to compile everything'
+            );
+    }
+
+    /**
+     * Build the compiler.
+     *
+     * @param OutputInterface $output Output object
+     *
+     * @return \VuFindTheme\AbstractCssPreCompiler
+     */
+    abstract protected function getCompiler(OutputInterface $output);
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $compiler = $this->getCompiler($output);
+        $compiler->setTempPath($this->cacheDir);
+        $compiler->compile(array_unique($input->getArgument('themes')));
+        return 0;
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/CssBuilderCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/CssBuilderCommand.php
index 28b650523bf..2ee3356ad22 100644
--- a/module/VuFindConsole/src/VuFindConsole/Command/Util/CssBuilderCommand.php
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/CssBuilderCommand.php
@@ -1,6 +1,6 @@
 <?php
 /**
- * Console command: build CSS.
+ * Console command: build CSS from LESS.
  *
  * PHP version 7
  *
@@ -28,13 +28,11 @@
 namespace VuFindConsole\Command\Util;
 
 use Symfony\Component\Console\Command\Command;
-use Symfony\Component\Console\Input\InputArgument;
-use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Output\OutputInterface;
 use VuFindTheme\LessCompiler;
 
 /**
- * Console command: build CSS.
+ * Console command: build CSS from LESS.
  *
  * @category VuFind
  * @package  Console
@@ -42,7 +40,7 @@ use VuFindTheme\LessCompiler;
  * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
  * @link     https://vufind.org/wiki/development Wiki
  */
-class CssBuilderCommand extends Command
+class CssBuilderCommand extends AbstractCssBuilderCommand
 {
     /**
      * The name of the command (the part after "public/index.php")
@@ -52,41 +50,11 @@ class CssBuilderCommand extends Command
     protected static $defaultName = 'util/cssBuilder';
 
     /**
-     * Cache directory for compiler
+     * Name of precompiler format
      *
      * @var string
      */
-    protected $cacheDir;
-
-    /**
-     * Constructor
-     *
-     * @param string      $cacheDir Cache directory for compiler
-     * @param string|null $name     The name of the command; passing null means it
-     * must be set in configure()
-     */
-    public function __construct($cacheDir, $name = null)
-    {
-        $this->cacheDir = $cacheDir;
-        parent::__construct($name);
-    }
-
-    /**
-     * Configure the command.
-     *
-     * @return void
-     */
-    protected function configure()
-    {
-        $this
-            ->setDescription('LESS compiler')
-            ->setHelp('Compiles CSS files from LESS.')
-            ->addArgument(
-                'themes',
-                InputArgument::IS_ARRAY | InputArgument::OPTIONAL,
-                'Name of theme(s) to compile; omit to compile everything'
-            );
-    }
+    protected $format = 'LESS';
 
     /**
      * Build the LESS compiler.
@@ -99,20 +67,4 @@ class CssBuilderCommand extends Command
     {
         return new LessCompiler($output);
     }
-
-    /**
-     * Run the command.
-     *
-     * @param InputInterface  $input  Input object
-     * @param OutputInterface $output Output object
-     *
-     * @return int 0 for success
-     */
-    protected function execute(InputInterface $input, OutputInterface $output)
-    {
-        $compiler = $this->getCompiler($output);
-        $compiler->setTempPath($this->cacheDir);
-        $compiler->compile(array_unique($input->getArgument('themes')));
-        return 0;
-    }
 }
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/ScssBuilderCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/ScssBuilderCommand.php
new file mode 100644
index 00000000000..708ab5ec580
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/ScssBuilderCommand.php
@@ -0,0 +1,70 @@
+<?php
+/**
+ * Console command: build CSS from SCSS.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * 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  Console
+ * @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 Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Output\OutputInterface;
+use VuFindTheme\ScssCompiler;
+
+/**
+ * Console command: build CSS from SCSS.
+ *
+ * @category VuFind
+ * @package  Console
+ * @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 Wiki
+ */
+class ScssBuilderCommand extends AbstractCssBuilderCommand
+{
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'util/scssBuilder';
+
+    /**
+     * Name of precompiler format
+     *
+     * @var string
+     */
+    protected $format = 'SCSS';
+
+    /**
+     * Build the LESS compiler.
+     *
+     * @param OutputInterface $output Output object
+     *
+     * @return ScssCompiler
+     */
+    protected function getCompiler(OutputInterface $output)
+    {
+        return new ScssCompiler($output);
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/ScssBuilderCommandFactory.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/ScssBuilderCommandFactory.php
new file mode 100644
index 00000000000..238f4024b0d
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/ScssBuilderCommandFactory.php
@@ -0,0 +1,65 @@
+<?php
+/**
+ * Factory for Util/ScssBuilder command.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * 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  Console
+ * @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 Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+use Interop\Container\ContainerInterface;
+use Laminas\ServiceManager\Factory\FactoryInterface;
+
+/**
+ * Factory for Util/ScssBuilder command.
+ *
+ * @category VuFind
+ * @package  Console
+ * @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 Wiki
+ */
+class ScssBuilderCommandFactory implements FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        $cacheManager = $container->get(\VuFind\Cache\Manager::class);
+        $cacheDir = $cacheManager->getCacheDir() . 'scss/';
+        return new $requestedName($cacheDir, ...($options ?? []));
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/ScssBuilderCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/ScssBuilderCommandTest.php
new file mode 100644
index 00000000000..8e2626ec5e7
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/ScssBuilderCommandTest.php
@@ -0,0 +1,69 @@
+<?php
+/**
+ * ScssBuilderCommand test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * 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\Command\Util;
+
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFindConsole\Command\Util\ScssBuilderCommand;
+
+/**
+ * ScssBuilderCommand test.
+ *
+ * @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 ScssBuilderCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Test that the command delegates proper behavior.
+     *
+     * @return void
+     */
+    public function testBasicOperation()
+    {
+        $cacheDir = '/foo';
+        $compiler = $this->getMockBuilder(\VuFindTheme\ScssCompiler::class)
+            ->disableOriginalConstructor()->getMock();
+        $compiler->expects($this->once())->method('setTempPath')
+            ->with($this->equalTo($cacheDir));
+        $compiler->expects($this->once())->method('compile')
+            ->with($this->equalTo(['foo', 'bar']));
+        $command = $this->getMockBuilder(ScssBuilderCommand::class)
+            ->setMethods(['getCompiler'])
+            ->setConstructorArgs([$cacheDir])
+            ->getMock();
+        $command->expects($this->once())->method('getCompiler')
+            ->will($this->returnValue($compiler));
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(['themes' => ['foo', 'bar', 'foo']]);
+        $this->assertEquals('', $commandTester->getDisplay());
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+}
diff --git a/module/VuFindTheme/src/VuFindTheme/AbstractCssPreCompiler.php b/module/VuFindTheme/src/VuFindTheme/AbstractCssPreCompiler.php
new file mode 100644
index 00000000000..9135eed6815
--- /dev/null
+++ b/module/VuFindTheme/src/VuFindTheme/AbstractCssPreCompiler.php
@@ -0,0 +1,220 @@
+<?php
+/**
+ * Abstract base class to precompile CSS within a theme.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * 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;
+
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * Abstract base class to precompile CSS within a 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
+ */
+abstract class AbstractCssPreCompiler
+{
+    /**
+     * Key in theme.config.php that lists all files
+     *
+     * @var string
+     */
+    protected $themeConfigKey;
+
+    /**
+     * Base path of VuFind.
+     *
+     * @var string
+     */
+    protected $basePath;
+
+    /**
+     * Temporary directory for cached files.
+     *
+     * @var string
+     */
+    protected $tempPath;
+
+    /**
+     * Fake base path used for generating absolute paths in CSS.
+     *
+     * @var string
+     */
+    protected $fakePath = '/zzzz_basepath_zzzz/';
+
+    /**
+     * Output object (set for logging)
+     *
+     * @var OutputInterface
+     */
+    protected $output;
+
+    /**
+     * Constructor
+     *
+     * @param OutputInterface $output Output interface for logging (optional)
+     */
+    public function __construct(OutputInterface $output = null)
+    {
+        $this->basePath = realpath(__DIR__ . '/../../../../');
+        $this->tempPath = sys_get_temp_dir();
+        $this->output = $output;
+    }
+
+    /**
+     * Compile scripts for the specified theme.
+     *
+     * @param string $theme Theme name
+     *
+     * @return void
+     */
+    abstract protected function processTheme($theme);
+
+    /**
+     * Set base path
+     *
+     * @param string $path Path to set
+     *
+     * @return void
+     */
+    public function setBasePath($path)
+    {
+        $this->basePath = $path;
+    }
+
+    /**
+     * Set temporary directory
+     *
+     * @param string $path Path to set
+     *
+     * @return void
+     */
+    public function setTempPath($path)
+    {
+        $this->tempPath = rtrim($path, '/');
+    }
+
+    /**
+     * Compile the scripts.
+     *
+     * @param array $themes Array of themes to process (empty for ALL themes).
+     *
+     * @return void
+     */
+    public function compile(array $themes)
+    {
+        if (empty($themes)) {
+            $themes = $this->getAllThemes();
+        }
+
+        foreach ($themes as $theme) {
+            $this->processTheme($theme);
+        }
+    }
+
+    /**
+     * Get all less files that might exist in a theme.
+     *
+     * @param string $theme Theme to retrieve files from
+     *
+     * @return array
+     */
+    protected function getAllFiles($theme)
+    {
+        $config = $this->basePath . '/themes/' . $theme . '/theme.config.php';
+        if (!file_exists($config)) {
+            return [];
+        }
+        $configArr = include $config;
+        $base = (isset($configArr['extends']))
+            ? $this->getAllFiles($configArr['extends'])
+            : [];
+        $current = $configArr[$this->themeConfigKey] ?? [];
+        return array_merge($base, $current);
+    }
+
+    /**
+     * Get a list of all available themes.
+     *
+     * @return array
+     */
+    protected function getAllThemes()
+    {
+        $baseDir = $this->basePath . '/themes/';
+        $dir = opendir($baseDir);
+        $list = [];
+        while ($line = readdir($dir)) {
+            if (is_dir($baseDir . $line)
+                && file_exists($baseDir . $line . '/theme.config.php')
+            ) {
+                $list[] = $line;
+            }
+        }
+        closedir($dir);
+        return $list;
+    }
+
+    /**
+     * Convert fake absolute paths to working relative paths.
+     *
+     * @param string $css  Generated CSS
+     * @param string $less Relative LESS filename
+     *
+     * @return string
+     *
+     * @SuppressWarnings(PHPMD.UnusedLocalVariable)
+     */
+    protected function makeRelative($css, $less)
+    {
+        // Figure out how deep the LESS file is nested -- this will
+        // affect our relative path. Note: we don't actually need
+        // to use $matches for anything, but some versions of PHP
+        // seem to be unhappy if we omit the parameter.
+        $depth = preg_match_all('|/|', $less, $matches);
+        $relPath = '../../../';
+        for ($i = 0; $i < $depth; $i++) {
+            $relPath .= '/../';
+        }
+        return str_replace($this->fakePath, $relPath, $css);
+    }
+
+    /**
+     * Log a message to the console
+     *
+     * @param string $str message string
+     *
+     * @return void
+     */
+    protected function logMessage($str)
+    {
+        if ($this->output) {
+            $this->output->writeln($str);
+        }
+    }
+}
diff --git a/module/VuFindTheme/src/VuFindTheme/LessCompiler.php b/module/VuFindTheme/src/VuFindTheme/LessCompiler.php
index d6bccff42af..80236ef7e8b 100644
--- a/module/VuFindTheme/src/VuFindTheme/LessCompiler.php
+++ b/module/VuFindTheme/src/VuFindTheme/LessCompiler.php
@@ -27,8 +27,6 @@
  */
 namespace VuFindTheme;
 
-use Symfony\Component\Console\Output\OutputInterface;
-
 /**
  * Class to compile LESS into CSS within a theme.
  *
@@ -38,89 +36,14 @@ use Symfony\Component\Console\Output\OutputInterface;
  * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
  * @link     https://vufind.org Main Site
  */
-class LessCompiler
+class LessCompiler extends AbstractCssPreCompiler
 {
     /**
-     * Base path of VuFind.
-     *
-     * @var string
-     */
-    protected $basePath;
-
-    /**
-     * Temporary directory for cached files.
-     *
-     * @var string
-     */
-    protected $tempPath;
-
-    /**
-     * Fake base path used for generating absolute paths in CSS.
+     * Key in theme.config.php that lists all files
      *
      * @var string
      */
-    protected $fakePath = '/zzzz_basepath_zzzz/';
-
-    /**
-     * Output object (set for logging)
-     *
-     * @var OutputInterface
-     */
-    protected $output;
-
-    /**
-     * Constructor
-     *
-     * @param OutputInterface $output Output interface for logging (optional)
-     */
-    public function __construct(OutputInterface $output = null)
-    {
-        $this->basePath = realpath(__DIR__ . '/../../../../');
-        $this->tempPath = sys_get_temp_dir();
-        $this->output = $output;
-    }
-
-    /**
-     * Set base path
-     *
-     * @param string $path Path to set
-     *
-     * @return void
-     */
-    public function setBasePath($path)
-    {
-        $this->basePath = $path;
-    }
-
-    /**
-     * Set temporary directory
-     *
-     * @param string $path Path to set
-     *
-     * @return void
-     */
-    public function setTempPath($path)
-    {
-        $this->tempPath = rtrim($path, '/');
-    }
-
-    /**
-     * Compile the scripts.
-     *
-     * @param array $themes Array of themes to process (empty for ALL themes).
-     *
-     * @return void
-     */
-    public function compile(array $themes)
-    {
-        if (empty($themes)) {
-            $themes = $this->getAllThemes();
-        }
-
-        foreach ($themes as $theme) {
-            $this->processTheme($theme);
-        }
-    }
+    protected $themeConfigKey = 'less';
 
     /**
      * Compile scripts for the specified theme.
@@ -131,7 +54,7 @@ class LessCompiler
      */
     protected function processTheme($theme)
     {
-        $lessFiles = $this->getAllLessFiles($theme);
+        $lessFiles = $this->getAllFiles($theme);
         if (empty($lessFiles)) {
             $this->logMessage("No LESS in " . $theme);
             return;
@@ -144,27 +67,6 @@ class LessCompiler
         }
     }
 
-    /**
-     * Get all less files that might exist in a theme.
-     *
-     * @param string $theme Theme to retrieve files from
-     *
-     * @return array
-     */
-    protected function getAllLessFiles($theme)
-    {
-        $config = $this->basePath . '/themes/' . $theme . '/theme.config.php';
-        if (!file_exists($config)) {
-            return [];
-        }
-        $configArr = include $config;
-        $base = (isset($configArr['extends']))
-            ? $this->getAllLessFiles($configArr['extends'])
-            : [];
-        $current = $configArr['less'] ?? [];
-        return array_merge($base, $current);
-    }
-
     /**
      * Compile a LESS file inside a theme.
      *
@@ -215,63 +117,4 @@ class LessCompiler
 
         $this->logMessage("\t\t" . (microtime(true) - $start) . ' sec');
     }
-
-    /**
-     * Convert fake absolute paths to working relative paths.
-     *
-     * @param string $css  Generated CSS
-     * @param string $less Relative LESS filename
-     *
-     * @return string
-     *
-     * @SuppressWarnings(PHPMD.UnusedLocalVariable)
-     */
-    protected function makeRelative($css, $less)
-    {
-        // Figure out how deep the LESS file is nested -- this will
-        // affect our relative path. Note: we don't actually need
-        // to use $matches for anything, but some versions of PHP
-        // seem to be unhappy if we omit the parameter.
-        $depth = preg_match_all('|/|', $less, $matches);
-        $relPath = '../../../';
-        for ($i = 0; $i < $depth; $i++) {
-            $relPath .= '/../';
-        }
-        return str_replace($this->fakePath, $relPath, $css);
-    }
-
-    /**
-     * Get a list of all available themes.
-     *
-     * @return array
-     */
-    protected function getAllThemes()
-    {
-        $baseDir = $this->basePath . '/themes/';
-        $dir = opendir($baseDir);
-        $list = [];
-        while ($line = readdir($dir)) {
-            if (is_dir($baseDir . $line)
-                && file_exists($baseDir . $line . '/theme.config.php')
-            ) {
-                $list[] = $line;
-            }
-        }
-        closedir($dir);
-        return $list;
-    }
-
-    /**
-     * Log a message to the console
-     *
-     * @param string $str message string
-     *
-     * @return void
-     */
-    protected function logMessage($str)
-    {
-        if ($this->output) {
-            $this->output->writeln($str);
-        }
-    }
 }
diff --git a/module/VuFindTheme/src/VuFindTheme/ScssCompiler.php b/module/VuFindTheme/src/VuFindTheme/ScssCompiler.php
new file mode 100644
index 00000000000..6cd99963ddb
--- /dev/null
+++ b/module/VuFindTheme/src/VuFindTheme/ScssCompiler.php
@@ -0,0 +1,108 @@
+<?php
+/**
+ * Class to compile SCSS into CSS within a theme.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2014.
+ *
+ * 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 SCSS into CSS within a 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 ScssCompiler extends AbstractCssPreCompiler
+{
+    /**
+     * Key in theme.config.php that lists all files
+     *
+     * @var string
+     */
+    protected $themeConfigKey = 'scss';
+
+    /**
+     * Compile scripts for the specified theme.
+     *
+     * @param string $theme Theme name
+     *
+     * @return void
+     */
+    protected function processTheme($theme)
+    {
+        // Get files
+        $files = $this->getAllFiles($theme);
+        if (empty($files)) {
+            $this->logMessage("No SCSS in " . $theme);
+            return;
+        }
+
+        // Build parent stack
+        $themeInfo = new ThemeInfo($this->basePath . '/themes', $theme);
+        $importPaths = [];
+        foreach (array_keys($themeInfo->getThemeInfo()) as $currTheme) {
+            $importPaths[] = $this->basePath . '/themes/' . $currTheme . '/scss/';
+        }
+
+        // Compile
+        $scss = new \ScssPhp\ScssPhp\Compiler();
+        $scss->setImportPaths($importPaths);
+        $this->logMessage('Processing ' . $theme);
+        $finalOutDir = $this->basePath . '/themes/' . $theme . '/css/';
+        foreach ($files as $key => $file) {
+            if ($key === 'active') {
+                continue;
+            }
+
+            $this->logMessage("\t" . $file);
+
+            // Check importPaths for file
+            $exists = false;
+            foreach ($importPaths as $path) {
+                if (file_exists($path . $file)) {
+                    $exists = true;
+                    break;
+                }
+            }
+            if (!$exists) {
+                $this->logMessage("\t\tnot found; skipping.");
+                continue;
+            }
+
+            $start = microtime(true);
+            $finalFile = $finalOutDir . str_replace('.scss', '.css', $file) . '.css';
+            if (!is_dir(dirname($finalFile))) {
+                mkdir(dirname($finalFile));
+            }
+            file_put_contents(
+                $finalOutDir . str_replace('.scss', '.css', $file),
+                $scss->compile('@import "' . $file . '";')
+            );
+            $this->logMessage("\t\t" . (microtime(true) - $start) . ' sec');
+        }
+    }
+}
diff --git a/module/VuFindTheme/tests/unit-tests/src/VuFindTest/LessCompilerTest.php b/module/VuFindTheme/tests/unit-tests/src/VuFindTest/CssPreCompilerTest.php
similarity index 63%
rename from module/VuFindTheme/tests/unit-tests/src/VuFindTest/LessCompilerTest.php
rename to module/VuFindTheme/tests/unit-tests/src/VuFindTest/CssPreCompilerTest.php
index 469e5ddffd0..f1e3d24f278 100644
--- a/module/VuFindTheme/tests/unit-tests/src/VuFindTest/LessCompilerTest.php
+++ b/module/VuFindTheme/tests/unit-tests/src/VuFindTest/CssPreCompilerTest.php
@@ -1,6 +1,6 @@
 <?php
 /**
- * LessCompiler Test Class
+ * CssPreCompilerTest Test Class
  *
  * PHP version 7
  *
@@ -28,9 +28,10 @@
 namespace VuFindTest;
 
 use VuFindTheme\LessCompiler;
+use VuFindTheme\ScssCompiler;
 
 /**
- * LessCompiler Test Class
+ * CssPreCompilerTest Test Class
  *
  * @category VuFind
  * @package  Tests
@@ -38,7 +39,7 @@ use VuFindTheme\LessCompiler;
  * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
  * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
  */
-class LessCompilerTest extends Unit\TestCase
+class CssPreCompilerTest extends Unit\TestCase
 {
     /**
      * Our brave test subject
@@ -50,110 +51,119 @@ class LessCompilerTest extends Unit\TestCase
     /**
      * Our brave test subject
      *
-     * @var LessCompiler
+     * @var $extCompiler
      */
     protected $compiler;
 
     /**
-     * Initial class setup.
+     * Data Provider for extensions and classes
      *
      * @return void
      */
-    public static function setUpBeforeClass(): void
+    public static function extClassProvider()
+    {
+        return [
+            ['less', LessCompiler::class],
+            ['scss', ScssCompiler::class]
+        ];
+    }
+
+    /**
+     * Create fixture files in temp folder
+     *
+     * @return void
+     */
+    protected function makeFakeThemeStructure($ext)
     {
         $temp = sys_get_temp_dir();
-        $testDest = $temp . '/vufind_less_comp_test/';
+        $testDest = $temp . "/vufind_${ext}_comp_test/";
         // Create directory structure, recursively
-        mkdir($testDest . 'themes/child/less', 0777, true);
+        mkdir($testDest . "themes/child/$ext", 0777, true);
         mkdir($testDest . 'themes/empty', 0777, true);
         mkdir($testDest . 'themes/parent/css', 0777, true);
-        mkdir($testDest . 'themes/parent/less/relative', 0777, true);
+        mkdir($testDest . "themes/parent/$ext/relative", 0777, true);
         file_put_contents(
             $testDest . 'themes/empty/theme.config.php',
             '<?php return array("extends"=>false);'
         );
         file_put_contents(
             $testDest . 'themes/parent/theme.config.php',
-            '<?php return array("extends"=>false, "less"=>array("compiled.less", "relative/relative.less"));'
+            "<?php return array(\"extends\"=>false, \"$ext\"=>array(\"compiled.$ext\", \"relative/relative.$ext\"));"
         );
         file_put_contents(
             $testDest . 'themes/child/theme.config.php',
-            '<?php return array("extends"=>"parent", "less"=>array("compiled.less", "missing.less"));'
+            "<?php return array(\"extends\"=>\"parent\", \"$ext\"=>array(\"compiled.$ext\", \"missing.$ext\"));"
         );
         file_put_contents(
-            $testDest . 'themes/parent/less/compiled.less',
+            $testDest . "themes/parent/$ext/compiled.$ext",
             '@import "parent";'
         );
         file_put_contents(
-            $testDest . 'themes/parent/less/parent.less',
+            $testDest . "themes/parent/$ext/parent.$ext",
             'body { background:url("../fake.png");color:#00D; a { color:#F00; } }'
         );
         file_put_contents(
-            $testDest . 'themes/parent/less/relative/relative.less',
+            $testDest . "themes/parent/$ext/relative/relative.$ext",
             'div {background:#EEE}'
         );
         file_put_contents(
-            $testDest . 'themes/child/less/compiled.less',
-            '@import "parent"; @black: #000; div {border:1px solid @black;}'
+            $testDest . "themes/child/$ext/compiled.$ext",
+            $ext == 'less'
+                ? '@import "parent"; @black: #000; div {border:1px solid @black;}'
+                : '@import "parent"; $black: #000; div {border:1px solid $black;}'
         );
     }
 
     /**
-     * Individual test setup.
+     * Initial class setup.
      *
      * @return void
      */
-    public function setUp(): void
+    public static function setUpBeforeClass(): void
     {
-        $temp = sys_get_temp_dir();
-        $perms = fileperms($temp);
-        $this->testDest = $temp . '/vufind_less_comp_test/';
-        if (!($perms & 0x0002)) {
-            $this->markTestSkipped('No write permissions in system temporary file');
+        foreach (self::extClassProvider() as [$ext, $class]) {
+            self::makeFakeThemeStructure($ext);
         }
-        $this->compiler = new LessCompiler();
-        $this->compiler->setBasePath($temp . '/vufind_less_comp_test');
-        $this->compiler->setTempPath($temp . '/vufind_less_comp_test/cache');
     }
 
     /**
-     * Final teardown method.
+     * Individual test setup.
      *
      * @return void
      */
-    public static function tearDownAfterClass(): void
+    public function setUp(): void
     {
         $temp = sys_get_temp_dir();
-        $testDest = $temp . '/vufind_less_comp_test/';
-        // Delete directory structure
-        self::delTree($testDest);
+        $perms = fileperms($temp);
+        if (!($perms & 0x0002)) {
+            $this->markTestSkipped('No write permissions in system temporary file');
+        }
     }
 
     /**
-     * Delete a directory tree; adapted from
-     * http://php.net/manual/en/function.rmdir.php
-     *
-     * @param string $dir Directory to delete.
+     * Assign appropriate values to $this->testDest and $this->compiler
      *
      * @return void
      */
-    protected static function delTree($dir)
+    protected function setupCompiler($ext, $class)
     {
-        $files = array_diff(scandir($dir), ['.', '..']);
-        foreach ($files as $file) {
-            is_dir("$dir/$file")
-                ? self::delTree("$dir/$file") : unlink("$dir/$file");
-        }
-        rmdir($dir);
+        $temp = sys_get_temp_dir();
+        $this->testDest = "$temp/vufind_${ext}_comp_test/";
+        $this->compiler = new $class();
+        $this->compiler->setBasePath("$temp/vufind_${ext}_comp_test");
+        $this->compiler->setTempPath("$temp/vufind_${ext}_comp_test/cache");
     }
 
     /**
      * Test compiling a single theme.
      *
+     * @dataProvider extClassProvider
+     *
      * @return void
      */
-    public function testThemeCompile()
+    public function testThemeCompile($ext, $class)
     {
+        $this->setupCompiler($ext, $class);
         $this->compiler->compile(['child']);
         $this->assertTrue(file_exists($this->testDest . 'themes/child/css/compiled.css'));
         $this->assertFalse(file_exists($this->testDest . 'themes/parent/css/compiled.css'));
@@ -161,12 +171,15 @@ class LessCompilerTest extends Unit\TestCase
     }
 
     /**
-     * Test compiling all themes.
+     * Test compiling all themes (default).
+     *
+     * @dataProvider extClassProvider
      *
      * @return void
      */
-    public function testAllCompile()
+    public function testAllCompile($ext, $class)
     {
+        $this->setupCompiler($ext, $class);
         $this->compiler->compile([]);
         $this->assertTrue(file_exists($this->testDest . 'themes/child/css/compiled.css'));
         $this->assertTrue(file_exists($this->testDest . 'themes/parent/css/compiled.css'));
@@ -175,4 +188,36 @@ class LessCompilerTest extends Unit\TestCase
         unlink($this->testDest . 'themes/parent/css/compiled.css');
         unlink($this->testDest . 'themes/parent/css/relative/relative.css');
     }
+
+    /**
+     * Delete a directory tree; adapted from
+     * http://php.net/manual/en/function.rmdir.php
+     *
+     * @param string $dir Directory to delete.
+     *
+     * @return void
+     */
+    protected static function delTree($dir)
+    {
+        $files = array_diff(scandir($dir), ['.', '..']);
+        foreach ($files as $file) {
+            is_dir("$dir/$file")
+                ? self::delTree("$dir/$file")
+                : unlink("$dir/$file");
+        }
+        rmdir($dir);
+    }
+
+    /**
+     * Final teardown method.
+     *
+     * @return void
+     */
+    public static function tearDownAfterClass(): void
+    {
+        $temp = sys_get_temp_dir();
+        // Delete directory structure
+        self::delTree("$temp/vufind_less_comp_test/");
+        self::delTree("$temp/vufind_scss_comp_test/");
+    }
 }
-- 
GitLab