diff --git a/Gruntfile.local.js b/Gruntfile.local.js index c2b85db1bba..479fcd77292 100644 --- a/Gruntfile.local.js +++ b/Gruntfile.local.js @@ -2,6 +2,7 @@ module.exports = function(grunt) { const fs = require("fs"); const path = require("path"); const os = require("node:os"); + const fsPromises = require("node:fs/promises"); grunt.registerTask("finna:scss", function finnaScssFunc() { const config = getFinnaSassConfig({ @@ -48,19 +49,38 @@ module.exports = function(grunt) { ext: '.scss' }; - let themeDir = grunt.option('theme-dir'); - if (themeDir) { - themeDir = path.resolve(themeDir); - } - const files = themeDir - ? [ + let files = []; + let viewsDir = grunt.option('views-dir'); + let themeDirs = grunt.option('theme-dirs'); + if (viewsDir) { + const isViewDir = function (dir) { + const stat = fs.lstatSync(dir); + return stat.isDirectory() && fs.existsSync(dir + '/themes/custom'); + }; + const entries = grunt.file.expand( { + filter: isViewDir, + }, + viewsDir + '/*/*' + ); + for (const viewDir of entries) { + files.push({ + ...sharedFileOpts, + cwd: viewDir + '/themes/custom/less', + dest: viewDir + '/themes/custom/scss' + }); + } + } else if (themeDirs) { + Object.entries(themeDirs.split(',')).forEach(([, themeDir]) => { + themeDir = path.resolve(themeDir); + files.push({ ...sharedFileOpts, cwd: themeDir + '/less', dest: themeDir + '/scss' - } - ] - : [ + }); + }); + } else { + files = [ { ...sharedFileOpts, cwd: 'themes/finna2/less', @@ -72,6 +92,7 @@ module.exports = function(grunt) { dest: 'themes/custom/scss' }, ]; + } const replacements = [ // Activate SCSS @@ -188,49 +209,13 @@ module.exports = function(grunt) { } ); } - if (grunt.option('replace-vars')) { - const vars = { - 'action-link-color': '#007c90', - 'gray-lighter': '#d1d1d1', - 'gray-ultralight': '#f7f7f7', - 'gray-light': '#595959', - 'gray-darker': '#000', - 'gray-dark': '#121212', - 'gray': '#2b2b2b', - 'body-bg': '#fff', - 'screen-xs': '480px', - 'screen-xs-min': '480px', - 'screen-xs-max': '767px', - 'screen-sm': '768px', - 'screen-sm-min': '768px', - 'screen-md': '992px', - 'screen-md-min': '992px', - 'navbar-default-link-color': '#fff', - 'content-font-size-base': '16px', - 'content-headings-font-size-h1': '28px', - 'content-headings-font-size-h2': '24px', - 'content-headings-font-size-h3': '21px', - 'content-headings-font-size-h4': '18px', - }; - let order = 20; - // Change variables where used (but not where declared!): - Object.entries(vars).forEach(([src, dst]) => { - replacements.push( - { - pattern: new RegExp("(.+)\\$(" + src + ")\\b", "g"), - replacement: '$1' + dst + ' /* $2 */', - order: order - } - ); - ++order; - }); - } - console.log(themeDir ? "Converting theme " + themeDir : "Converting Finna default themes"); + console.log(themeDirs || viewsDir ? "Converting specified themes" : "Converting Finna default themes"); grunt.config.set('lessToSass', { convert: { files: files, options: { + excludes: ['important'], replacements: replacements } } diff --git a/module/FinnaConsole/config/lessToScss.config.php b/module/FinnaConsole/config/lessToScss.config.php new file mode 100644 index 00000000000..831640eb855 --- /dev/null +++ b/module/FinnaConsole/config/lessToScss.config.php @@ -0,0 +1,348 @@ + [ + [ // functions + 'pattern' => '/(?!@debug|@import|@media|@keyframes|@font-face|@include|@extend|@mixin|@supports|@container |@if |@use |@page |@-\w)@/i', + 'replacement' => '$', + ], + [ // when => if + 'pattern' => '/\.([\w_-]*)\s*\((.*)\)\s*when\s*\((.*)=(.*)\)\s*\{(\s*)([^}]+)}[;]?/i', + 'replacement' => '@if $3==$4 {$5@mixin $1($2){$5$6}}', + ], + [ // .class => @extend .class + 'pattern' => '/\.([[a-zA-Z-_]*)\s*;/i', + 'replacement' => '@extend .$1;', + ], + [ // Remove .less extension from imports + 'pattern' => "/\@import\s*[\"'](.*).less[\"']/i", + 'replacement' => function ($matches) { + return '@import \'' . str_replace('/less/', '/scss/', $matches[1]) . '\''; + }, + ], + [ // Nested include + 'pattern' => '/(\s*)\#([\w\-]*)\s*>\s*\@include\s+(.*);/i', + 'replacement' => '$1@include $2-$3;', + ], + [ // Include mixin + 'pattern' => '/(\s+)\.([\w\-]*)\s*\((.*)\);/i', + 'replacement' => '$1@include $2($3);', + ], + [ // Mixin declaration + 'pattern' => '/\.([\w\-]*)\s*\((.*)\)\s*\{/i', + 'replacement' => '@mixin $1($2){', + ], + [ + 'pattern' => '/spin\((.+),(.+)\)/i', + 'replacement' => 'adjust-hue($1,$2)', + ], + [ // shade/tint + 'pattern' => '/(shade|tint)\(([^,]+),\s?([\d%]+)\)/i', + 'replacement' => function ($matches) { + [, $method, $color2, $weight] = $matches; + $color1 = $method === 'shade' ? '#000000' : '#ffffff'; + return "mix($color1, $color2, $weight)"; + }, + ], + [ // fade + 'pattern' => '/fade\((.*),\s?([\d]+)\%\)/mi', + 'replacement' => 'rgba($1, ($2/100))', + ], + [ // literal + 'pattern' => '/~"(.*)"/i', + 'replacement' => function ($matches) { + [, $match] = $matches; + // Keep media queries quoted + if (str_starts_with($match, 'screen and')) { + return '"' . $match . '"'; + } + return 'unquote("' . $match . '")'; + }, + ], + // end of basic less-to-sass rules --------------- + + [ // Activate SCSS + 'pattern' => '/\/\* #SCSS>/i', + 'replacement' => '/* #SCSS> */', + ], + [ + 'pattern' => '/<#SCSS \*\//i', + 'replacement' => '/* <#SCSS */', + ], + [ + 'pattern' => '/\/\* #LESS> \*\//i', + 'replacement' => '/* #LESS>', + ], + [ + 'pattern' => '/\/\* <#LESS \*\//i', + 'replacement' => '<#LESS */', + ], + [ // Fix include parameter separator + 'pattern' => '/@include ([^\(]+)\(([^\)]+)\);/i', + 'replacement' => function ($matches) { + [, $m1, $m2] = $matches; + return '@include ' . $m1 . '(' . str_replace(';', ',', $m2) . ');'; + }, + ], + [ // Fix tilde literals + 'pattern' => "/~'(.*?)'/i", + 'replacement' => '$1', + ], + [ // Convert inline &:extends + 'pattern' => '/&:extend\(([^\)]+?)( all)?\)/i', + 'replacement' => '@extend $1', + ], + [ // Wrap variables in calcs with #{} + 'pattern' => '/calc\([^;]+/i', + 'replacement' => function ($matches) { + return preg_replace('/(\$[\w\-]+)/i', '#{$1}', $matches[0]); + }, + ], + [ // Wrap variables set to css variables with #{} + 'pattern' => '/(--[\w:-]+:\s*)((\$|darken\(|lighten\()[^;]+)/i', + 'replacement' => '$1#{$2}', + ], + [ // Remove !default from extends (icons.scss) + 'pattern' => '/@extend ([^;}]+) !default;/i', + 'replacement' => '@extend $1;', + ], + + [ // Fix comparison: + 'pattern' => '/ ==< /i', + 'replacement' => ' <= ', + ], + [ // Remove !important from variables: + 'pattern' => '/^[^(]*(\$.+?):(.+?)\s*!important\s*;/m', + 'replacement' => '$1:$2;', + ], + /* [ // Remove !important from functions: + 'pattern' => '/^[^(]*(\$.+?):(.+?)\s*!important\s*\)/m', + 'replacement' => '$1:$2;', + ],*/ + [ // fadein => fade-in: + 'pattern' => '/fadein\((\S+),\s*(\S+)\)/', + 'replacement' => function ($matches) { + return 'fade-in(' . $matches[1] . ', ' . (str_replace('%', '', $matches[2]) / 100) . ')'; + }, + ], + [ // fadeout => fade-out: + 'pattern' => '/fadeout\((\S+),\s*(\S+)\)/', + 'replacement' => function ($matches) { + return 'fade-out(' . $matches[1] . ', ' . (str_replace('%', '', $matches[2]) / 100) . ')'; + }, + ], + [ // replace invalid characters in variable names: + 'pattern' => '/\$([^: };\/]+)/', + 'replacement' => function ($matches) { + return '$' . str_replace('.', '__', $matches[1]); + }, + ], + [ // remove invalid &: + 'pattern' => '/([a-zA-Z0-9])&:/', + 'replacement' => '$1:', + ], + [ // remove (reference) from import): + 'pattern' => '/@import\s+\(reference\)\s*/', + 'replacement' => '@import /*(reference)*/ ', + ], + [ // fix missing semicolon from background-image rule: + 'pattern' => '/(\$background-image:([^;]+?))\n/', + 'replacement' => "\$1;\n", + ], + [ // remove broken (and useless) rule: + 'pattern' => '/\.feed-container \.list-feed \@include feed-header\(\);/', + 'replacement' => '', + ], + [ // interpolate variables in media queries: + 'pattern' => '/\@media (\$[^ ]+)/', + 'replacement' => '@media #{$1}', + ], + [ // missing semicolon: + 'pattern' => '/(:.*auto)\n/', + 'replacement' => "\$1;\n", + ], + [ // lost space in mixin declarations: + 'pattern' => '/(\@mixin.+){/', + 'replacement' => '$1 {', + ], + [ // special cases: mobile mixin + 'pattern' => '/\.mobile\(\{(.*?)\}\);/s', + 'replacement' => '@media #{$mobile} { & { $1 } }', + ], + [ // special cases: mobile mixin 2 + 'pattern' => '@mixin mobile($rules){', + 'replacement' => '@mixin mobile {', + ], + [ // special cases: mobile mixin 3 + 'pattern' => '$rules();', + 'replacement' => '@content;', + ], + [ // invalid mixin name + 'pattern' => 'text(uppercase)', + 'replacement' => 'text-uppercase', + ], + [ // when isnumber + 'pattern' => '& when (isnumber($z-index))', + 'replacement' => '@if $z-index != null', + ], + [ // blocks extending container + 'pattern' => '@include container();', + 'replacement' => '@extend .container;', + ], + [ // blocks extending more-link + 'pattern' => '@include more-link();', + 'replacement' => '@extend .more-link;', + ], + [ // fix math operations + 'pattern' => '/(\s+)(\(.+\/.+\))/', + 'replacement' => '$1calc$2', + ], + [ // typo + 'pattern' => '$carousel-header-color none;', + 'replacement' => '$carousel-header-color: none;', + ], + [ // typo + 'pattern' => '$brand-primary // $link-color;', + 'replacement' => '$brand-primary; // $link-color', + ], + [ // typo + 'pattern' => '- aukioloaikojen otsikko', + 'replacement' => '{ /* aukioloaikojen otsikko */ }', + ], + [ // typo + 'pattern' => '$link-hover-color: $tut-a-hover,', + 'replacement' => '$link-hover-color: $tut-a-hover;', + ], + [ // typo + 'pattern' => 'rgba(43,65,98,0,9)', + 'replacement' => 'rgba(43,65,98,0.9)', + ], + [ // typo $input-bg: ##ff8d0f; + 'pattern' => '/:\s*##+/', + 'replacement' => ': #', + ], + [ // typo + 'pattern' => '!importanti', + 'replacement' => '!important', + ], + [ // typo + 'pattern' => '$brand-secondary: #;', + 'replacement' => '', + ], + [ // typo + 'pattern' => '$brand-secondary: ###;', + 'replacement' => '', + ], + [ // typo + 'pattern' => '#00000;', + 'replacement' => '#000000;', + ], + [ // typo + 'pattern' => 'background-color: ;', + 'replacement' => '', + ], + [ // typo + 'pattern' => '$header-background-color #fff;', + 'replacement' => '$header-background-color: #fff;', + ], + [ // typo + 'pattern' => '$action-link-color #FFF;', + 'replacement' => '$action-link-color: #FFF;', + ], + [ // typo + 'pattern' => '$finna-browsebar-background (selaa palkin taustaväri)', + 'replacement' => '//$finna-browsebar-background (selaa palkin taustaväri)', + ], + [ // typo + 'pattern' => '$finna-browsebar-link-color(selaa palkin linkin)', + 'replacement' => '//$finna-browsebar-link-color(selaa palkin linkin)', + ], + [ // typo + 'pattern' => '$finna-browsebar-highlight-background (selaa palkin korotuksen taustaväri)', + 'replacement' => '//$finna-browsebar-highlight-background (selaa palkin korotuksen taustaväri)', + ], + [ // typo + 'pattern' => '$home-2_fi {', + 'replacement' => '.home-2_fi {', + ], + [ // Convert unsupported nested extend + 'pattern' => '@extend .finna-panel-default .panel-heading;', + 'replacement' => << '@extend .finna-panel-default .finna-panel-heading-inner;', + 'replacement' => << '#gradient.vertical($background-start-color; $background-end-color; $background-start-percent; $background-end-percent);', + 'replacement' => 'background-image: linear-gradient(to bottom, $background-start-color $background-start-percent, $background-end-color $background-end-percent);', + ], + [ // common typo in home column styles + 'pattern' => '/(\.home-1, \.home-3 \{[^}]+)}(\s*\n\s*\& \.left-column-content.*?\& .right-column-content \{.*?\}.*?\})/s', + 'replacement' => "\$1\$2\n}", + ], + [ // another typo in home column styles + 'pattern' => '/(\n\s+\.left-column-content.*?\n\s+)& (.right-column-content)/s', + 'replacement' => '$1$2', + ], + [ // missing semicolon: display: none + 'pattern' => '/display: none\n/', + 'replacement' => 'display: none;', + ], + [ // missing semicolon in variable definitions + 'pattern' => '/(\n\s*\$[a-zA-Z0-9_-]+\s*:\s*?[^;\s]+)((\n|\s*\/\/))/', + 'replacement' => '$1;$2', + ], + [ // missing semicolon: $header-text-color: #000000 + 'pattern' => '/$header-text-color: #000000\n/', + 'replacement' => '$header-text-color: #000000;', + ], + [ // missing semicolon: clip: rect(0px,1200px,1000px,0px) + 'pattern' => '/clip: rect\(0px,1200px,1000px,0px\)\n/', + 'replacement' => "clip: rect(0px,1200px,1000px,0px);\n", + ], + [ // missing semicolon: $finna-feedback-background: darken(#d80073, 10%) // + 'pattern' => '/\$finna-feedback-background: darken\(#d80073, 10%\)\s*?(\n|\s*\/\/)/', + 'replacement' => '$finna-feedback-background: darken(#d80073, 10%);$1', + ], + [ // invalid (and obsolete) rule + 'pattern' => '/(\@supports\s*\(-ms-ime-align:\s*auto\)\s*\{\s*\n\s*clip-path.*?\})/s', + 'replacement' => "// Invalid rule commented out by SCSS conversion\n/*\n\$1\n*/", + ], + + [ // literal fix + 'pattern' => "~ ')'", + 'replacement' => ')', + ], + [ // literal fix + 'pattern' => 'calc(100vh - "#{$navbar-height}~")', + 'replacement' => 'calc(100vh - #{$navbar-height})', + ], + [ // math without calc + 'pattern' => '/(.*\s)(\S+ \/ (\$|\d)[^\s;]*)/', + 'replacement' => function ($matches) { + [$full, $pre, $math] = $matches; + if (str_contains($matches[1], '(')) { + return $full; + } + return $pre . "calc($math)"; + }, + ], + [ // variable interpolation + 'pattern' => '/\$\{([A-Za-z0-9_-]+)\}/', + 'replacement' => '#{\$$1}', + ], + ], +]; diff --git a/module/FinnaConsole/config/module.config.php b/module/FinnaConsole/config/module.config.php index d3a6661c294..2563c44f661 100644 --- a/module/FinnaConsole/config/module.config.php +++ b/module/FinnaConsole/config/module.config.php @@ -20,6 +20,7 @@ 'FinnaConsole\Command\Util\ExpireFinnaCacheCommand' => 'FinnaConsole\Command\Util\ExpireFinnaCacheCommandFactory', 'FinnaConsole\Command\Util\ExpireUsers' => 'FinnaConsole\Command\Util\ExpireUsersFactory', 'FinnaConsole\Command\Util\ImportComments' => 'FinnaConsole\Command\Util\ImportCommentsFactory', + 'FinnaConsole\Command\Util\LessToScssCommand' => 'FinnaConsole\Command\Util\LessToScssCommandFactory', 'FinnaConsole\Command\Util\OnlinePaymentMonitor' => 'FinnaConsole\Command\Util\OnlinePaymentMonitorFactory', 'FinnaConsole\Command\Util\ProcessRecordStatsLog' => 'FinnaConsole\Command\Util\ProcessRecordStatsLogFactory', 'FinnaConsole\Command\Util\ProcessStatsQueue' => 'FinnaConsole\Command\Util\ProcessStatsQueueFactory', @@ -39,6 +40,7 @@ 'util/expire_finna_cache' => 'FinnaConsole\Command\Util\ExpireFinnaCacheCommand', 'util/expire_users' => 'FinnaConsole\Command\Util\ExpireUsers', 'util/import_comments' => 'FinnaConsole\Command\Util\ImportComments', + 'util/less_to_scss' => 'FinnaConsole\Command\Util\LessToScssCommand', 'util/online_payment_monitor' => 'FinnaConsole\Command\Util\OnlinePaymentMonitor', 'util/process_record_stats' => 'FinnaConsole\Command\Util\ProcessRecordStatsLog', 'util/verify_record_links' => 'FinnaConsole\Command\Util\VerifyRecordLinks', diff --git a/module/FinnaConsole/src/FinnaConsole/Command/Util/LessToScssCommand.php b/module/FinnaConsole/src/FinnaConsole/Command/Util/LessToScssCommand.php new file mode 100644 index 00000000000..f71c908b0fd --- /dev/null +++ b/module/FinnaConsole/src/FinnaConsole/Command/Util/LessToScssCommand.php @@ -0,0 +1,940 @@ + + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ + +namespace FinnaConsole\Command\Util; + +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Formatter\OutputFormatter; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use VuFind\Config\PathResolver; + +use function array_key_exists; +use function count; +use function dirname; +use function in_array; +use function is_string; + +/** + * Console command: convert style files from LESS to SCSS (Sass). + * + * @category VuFind + * @package Console + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ +#[AsCommand( + name: 'util/lessToScss', + description: 'LESS to SCSS conversion' +)] +class LessToScssCommand extends Command +{ + public const VARIABLE_CHARS = '[a-zA-Z0-9_-]'; + + /** + * Include paths + * + * @var array + */ + protected $includePaths = []; + + /** + * Console output + * + * @var OutputInterface + */ + protected $output = null; + + /** + * All variables with the last occurrence taking precedence (like in lesscss) + * + * @var array + */ + protected $allLessVars = []; + + /** + * Source directory (LESS) + * + * @var string + */ + protected $sourceDir = ''; + + /** + * Target directory (SCSS) + * + * @var string + */ + protected $targetDir = ''; + + /** + * An array tracking all processed files + * + * @var array + */ + protected $allFiles = []; + + /** + * File to use for all added variables + * + * @var ?string + */ + protected $variablesFile = null; + + /** + * Files excluded from processing + * + * @var array + */ + protected $excludedFiles = []; + + /** + * Substitutions (regexp and string replace) + * + * @var array + */ + protected $substitutions = []; + + /** + * Whether to enable SCSS in target theme(s) + * + * @var bool + */ + protected $enableScss = false; + + /** + * Patterns for files that must not use the !default flag for variables + * + * @var array + */ + protected $noDefaultFiles = []; + + /** + * Constructor + * + * @param PathResolver $pathResolver Config path resolver + */ + public function __construct(protected PathResolver $pathResolver) + { + parent::__construct(); + } + + /** + * Configure the command. + * + * @return void + */ + protected function configure() + { + $this + ->setHelp('Converts LESS styles to SCSS') + ->addOption( + 'variables_file', + null, + InputOption::VALUE_REQUIRED, + 'File to use for added SCSS variables (may be relative to the target directory)' + ) + ->addOption( + 'include_path', + 'I', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'Include directories for SCSS parser' + ) + ->addOption( + 'exclude', + null, + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'Files to skip as main LESS files (fnmatch patterns)' + ) + ->addOption( + 'no_default', + null, + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'Files where variable definitions must not include the !default flag (fnmatch patterns relative to' + . ' the target directory)' + ) + ->addOption( + 'enable_scss', + null, + InputOption::VALUE_NONE, + 'If specified, enables SCSS in the target theme(s)', + ) + ->addArgument( + 'main_file', + InputArgument::REQUIRED | InputArgument::IS_ARRAY, + 'Main LESS file to use as entry point. Can also be a glob pattern.' + ); + } + + /** + * 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) + { + $this->output = $output; + $this->substitutions = $this->getSubstitutions(); + $this->includePaths = $input->getOption('include_path'); + $this->excludedFiles = $input->getOption('exclude'); + $this->noDefaultFiles = $input->getOption('no_default'); + $this->enableScss = $input->getOption('enable_scss'); + $variablesFile = $input->getOption('variables_file'); + $patterns = $input->getArgument('main_file'); + + foreach ($patterns as $pattern) { + foreach (glob($pattern) as $mainFile) { + foreach ($this->excludedFiles as $exclude) { + if (fnmatch($exclude, $mainFile)) { + continue 2; + } + } + $this->output->writeln("Processing $mainFile"); + $this->allFiles = []; + $this->allLessVars = []; + + $this->sourceDir = dirname($mainFile); + $this->targetDir = preg_replace('/\/less\b/', '/scss', $this->sourceDir); + $this->variablesFile = $variablesFile + ? $this->sourceDir . '/' . preg_replace('/\.scss$/', '', $variablesFile) + : null; + // First read all vars: + if (!$this->discoverLess($mainFile, $this->allLessVars)) { + return Command::FAILURE; + } + // Now do changes: + $currentVars = []; + if (!$this->processFile($mainFile, $currentVars)) { + $this->error('Stop on failure'); + return Command::FAILURE; + } + + // Write out the target files: + if (!$this->writeTargetFiles()) { + return Command::FAILURE; + } + } + } + return Command::SUCCESS; + } + + /** + * Discover less variables + * + * @param string $filename File name + * @param array $vars Currently defined variables + * + * @return bool + */ + protected function discoverLess(string $filename, array &$vars): bool + { + if (!$this->isReadableFile($filename)) { + $this->error("File $filename does not exist or is not a readable file"); + return false; + } + $fileDir = dirname($filename); + $lineNo = 0; + $this->debug("Start processing $filename (discovery)", OutputInterface::VERBOSITY_DEBUG); + $lines = file($filename, FILE_IGNORE_NEW_LINES); + + $inMixin = 0; + $inComment = false; + foreach ($lines as $line) { + ++$lineNo; + if (trim($line) === '') { + continue; + } + $lineId = "$filename:$lineNo"; + $parts = explode('//', $line, 2); + $line = $parts[0]; + + $cStart = strpos($line, '/*'); + $cEnd = strrpos($line, '*/'); + if (false !== $cStart && (false === $cEnd || $cEnd < $cStart)) { + $inComment = true; + } elseif (false !== $cEnd) { + $inComment = false; + } + if ($inComment) { + continue; + } + + if (preg_match('/\.([\w\-]*)\s*\((.*)\)\s*\{/i', trim($line))) { + $inMixin = $this->getBlockLevelChange($line); + continue; + } + if ($inMixin) { + $inMixin += $this->getBlockLevelChange($line); + } + if ($inMixin) { + continue; + } + + // Process variable declarations: + $this->processLessVariables($lineId, $line, $vars); + // Process import: + if (!$this->processImports($lineId, $fileDir, $line, $vars, true)) { + return false; + } + } + return true; + } + + /** + * Process a file + * + * @param string $filename File name + * @param array $vars Currently defined variables + * + * @return bool + */ + protected function processFile(string $filename, array &$vars): bool + { + if (!$this->isReadableFile($filename)) { + $this->error("File $filename does not exist or is not a readable file"); + return false; + } + $fileDir = dirname($filename); + $inSourceDir = $this->isInSourceDir($fileDir); + $lineNo = 0; + $this->debug("Start processing $filename (conversion)", OutputInterface::VERBOSITY_DEBUG); + $lines = file($filename, FILE_IGNORE_NEW_LINES); + + // Process string substitutions + if (!str_ends_with($filename, '.scss')) { + $lines = explode(PHP_EOL, $this->processSubstitutions($filename, implode(PHP_EOL, $lines))); + $this->updateFileCollection($filename, compact('lines', 'vars')); + } + + $inMixin = 0; + $inComment = false; + $requiredVars = []; + foreach ($lines as $idx => $line) { + ++$lineNo; + if (trim($line) === '') { + continue; + } + $lineId = "$filename:$lineNo"; + $parts = explode('//', $line, 2); + $line = $parts[0]; + $comments = $parts[1] ?? null; + + if (str_starts_with(trim($line), '@mixin ')) { + $inMixin = $this->getBlockLevelChange($line); + continue; + } + if ($inMixin) { + $inMixin += $this->getBlockLevelChange($line); + } + if ($inMixin) { + continue; + } + + $cStart = strpos($line, '/*'); + $cEnd = strrpos($line, '*/'); + if (false !== $cStart && (false === $cEnd || $cEnd < $cStart)) { + $inComment = true; + } elseif (false !== $cEnd) { + $inComment = false; + } + if ($inComment) { + continue; + } + + // Process variable declarations: + $this->processScssVariables($lineId, $line, $vars); + // Process imports: + if (!$this->processImports($lineId, $fileDir, $line, $vars, false)) { + return false; + } + + // Collect variables that need to be defined: + if ($inSourceDir) { + if ($newVars = $this->checkVariables($lineId, $line, $vars)) { + $requiredVars = [ + ...$requiredVars, + ...$newVars, + ]; + } + } + $lines[$idx] = $line . ($comments ? "//$comments" : ''); + } + + $this->updateFileCollection($filename, compact('lines', 'requiredVars')); + + return true; + } + + /** + * Check if path is in source directory + * + * @param string $path Path + * + * @return bool + */ + protected function isInSourceDir(string $path): bool + { + return str_starts_with($path, $this->sourceDir) && !str_contains($path, '..'); + } + + /** + * Check if path is in target directory + * + * @param string $path Path + * + * @return bool + */ + protected function isInTargetDir(string $path): bool + { + return str_starts_with($path, $this->targetDir) && !str_contains($path, '..'); + } + + /** + * Find variables in LESS + * + * @param string $lineId Line identifier for logging + * @param string $line Line + * @param array $vars Currently defined variables + * + * @return ?array Array of required variables and their valuesm, or null on error + */ + protected function processLessVariables(string $lineId, string $line, array &$vars): void + { + if (!preg_match('/^\s*\@(' . static::VARIABLE_CHARS . '+):\s*(.*?);?\s*$/', $line, $matches)) { + return; + } + [, $var, $value] = $matches; + $value = trim(preg_replace('/\s*!default\s*;?\s*$/', '', $value)); + if (array_key_exists($var, $vars)) { + $this->debug( + "$lineId: `$var: $value` overrides existing value `" . $vars[$var] . '`', + OutputInterface::VERBOSITY_DEBUG + ); + } else { + $this->debug("$lineId: found `$var: $value`", OutputInterface::VERBOSITY_DEBUG); + } + + $vars[$var] = $value; + } + + /** + * Find variables + * + * @param string $lineId Line identifier for logging + * @param string $line Line + * @param array $vars Currently defined variables + * + * @return ?array Array of required variables and their valuesm, or null on error + */ + protected function processScssVariables(string $lineId, string $line, array &$vars): void + { + if (!preg_match('/^\s*\$(' . static::VARIABLE_CHARS . '+):\s*(.*?);?$/', $line, $matches)) { + return; + } + [, $var, $value] = $matches; + $value = trim(preg_replace('/\s*!default\s*;?\s*$/', '', $value, -1, $count)); + $default = $count > 0; + $existing = $vars[$var] ?? null; + if ($existing) { + if ($existing['default'] && !$default) { + $this->debug( + "$lineId: `$var: $value` overrides default value `" . $vars[$var]['value'] . '`', + OutputInterface::VERBOSITY_DEBUG + ); + } else { + return; + } + } else { + $this->debug("$lineId: found `$var: $value`", OutputInterface::VERBOSITY_DEBUG); + } + $vars[$var] = compact('value', 'default'); + } + + /** + * Process imports + * + * @param string $lineId Line identifier for logging + * @param string $fileDir Current file directory + * @param string $line Line + * @param array $vars Currently defined variables + * @param bool $discover Whether to just discover files and their content + * + * @return bool + */ + protected function processImports(string $lineId, string $fileDir, string $line, array &$vars, bool $discover): bool + { + if (!preg_match("/^\s*@import\s+['\"]([^'\"]+)['\"]\s*;/", $line, $matches)) { + // Check for LESS import reference: + if (!preg_match("/^\s*@import \/\*\(reference\)\*\/ ['\"]([^'\"]+)['\"]\s*;/", $line, $matches)) { + return true; + } + } + $import = $matches[1]; + if (str_ends_with($import, '.css')) { + $this->debug("$lineId: skipping .css import"); + return true; + } + if (!($fullPath = $this->resolveImportFileName($import, $fileDir))) { + $targetFileDir = str_replace($this->sourceDir, $this->targetDir, $fileDir); + $targetFileDir = str_replace('/less', '/scss', $targetFileDir); + $targetImport = str_replace('/less/', '/scss/', $import); + if (!($fullPath = $this->resolveImportFileName($targetImport, $targetFileDir))) { + $this->error("$lineId: import file $import not found"); + return false; + } + } else { + $this->debug("$lineId: import $fullPath as $import", OutputInterface::VERBOSITY_DEBUG); + if ($discover) { + if (!$this->discoverLess($fullPath, $vars)) { + return false; + } + } else { + if (!$this->processFile($fullPath, $vars)) { + return false; + } + } + } + return true; + } + + /** + * Find import file + * + * @param string $filename Relative file name + * @param string $baseDir Base directory + * + * @return ?string + */ + protected function resolveImportFileName(string $filename, string $baseDir): ?string + { + $allDirs = [ + $baseDir, + ...$this->includePaths, + ]; + $filename = preg_replace('/\.(less|scss)$/', '', $filename); + foreach (['less', 'scss'] as $extension) { + foreach ($allDirs as $dir) { + // full path + $fullPath = "$dir/$filename.$extension"; + if (!$this->isReadableFile($fullPath)) { + // reference import + $fullPath = dirname($fullPath) . '/_' . basename($fullPath); + } + if ($this->isReadableFile($fullPath)) { + return $fullPath; + } + } + } + return null; + } + + /** + * Replace variables that are defined later with their last values + * + * @param string $lineId Line identifier for logging + * @param string $line Line + * @param array $vars Currently defined variables + * + * @return ?array Array of required variables and their values, or null on error + */ + protected function checkVariables(string $lineId, string $line, array $vars): ?array + { + $required = []; + preg_match_all('/\$(' . static::VARIABLE_CHARS . '+)(?!.*:)\\b/', $line, $allMatches); + foreach ($allMatches[1] ?? [] as $var) { + $lessVal = $this->allLessVars[$var] ?? null; + if ( + isset($vars[$var]) + && null !== $lessVal + && $vars[$var]['value'] === $this->processSubstitutions('', $lessVal) + ) { + // Previous definition contains the correct value: + $this->debug("$lineId: $var ok", OutputInterface::VERBOSITY_VERY_VERBOSE); + continue; + } + if (null === $lessVal) { + $this->warning("$lineId: Value for variable `$var` not found (line: $line)"); + continue; + } + // Use last defined value: + + $this->debug("$lineId: Need `$lessVal` for $var (have `" . ($vars[$var]['value'] ?? '[nothing]') . '`)'); + $required[] = [ + 'var' => $var, + 'value' => $lessVal, + ]; + } + return $required; + } + + /** + * Resolve requirements for variables that depend on other variables + * + * @param array $vars Variables to resolve + * @param array $knownVars Vars that are already available + * + * @return array + */ + protected function resolveVariableDependencies(array $vars, array $knownVars): array + { + $result = $vars; + foreach ($vars as $current) { + $var = $current['var']; + $varDefinition = $current['value']; + $loop = 0; + while (preg_match('/[@\$]\{?(' . static::VARIABLE_CHARS . '+)/', $varDefinition, $matches)) { + $requiredVar = $matches[1]; + if (in_array($requiredVar, $knownVars)) { + $this->debug( + "Existing definition found for '$requiredVar' required by `$var: $varDefinition`", + OutputInterface::VERBOSITY_DEBUG + ); + continue; + } + if ($requiredVarValue = $this->allLessVars[$requiredVar] ?? null) { + $this->debug("`$var: $varDefinition` requires `$requiredVar: $requiredVarValue`"); + $result[] = [ + 'var' => $requiredVar, + 'value' => $requiredVarValue, + ]; + $varDefinition = $requiredVarValue; + } else { + $this->warning( + "Could not resolve dependency for variable `$var`; definition missing for `$requiredVar`" + ); + break; + } + if (++$loop >= 10) { + $this->warning("Value definition loop detected ($var -> $requiredVar)"); + break; + } + } + } + return $result; + } + + /** + * Get block level (depth) change + * + * @param string $line Line + * + * @return int + */ + protected function getBlockLevelChange(string $line): int + { + $level = 0; + foreach (str_split($line) as $ch) { + if ('{' === $ch) { + ++$level; + } elseif ('}' === $ch) { + --$level; + } + } + return $level; + } + + /** + * Update a file in the all files collection + * + * @param string $filename File name + * @param array $values Values to set + * + * @return void; + */ + protected function updateFileCollection(string $filename, array $values): void + { + $barename = preg_replace('/\.(less|scss)$/', '', $filename); + if (null === ($oldValues = $this->allFiles[$barename] ?? null)) { + $oldValues = [ + 'requiredVars' => [], + ]; + $values['index'] = count($this->allFiles); + } + if (!isset($oldValues['lines']) && !isset($values['lines'])) { + // Read in any existing file: + if (file_exists($filename)) { + if (!$this->isReadableFile($filename)) { + throw new \Exception("$filename is not readable"); + } + $values['lines'] = file($filename, FILE_IGNORE_NEW_LINES); + } + } + // Merge requiredVars in case the file is imported multiple times + // (we may end up with duplicates, but it's difficult to dedup without affecting order, so keep it that way): + $values['requiredVars'] = array_merge( + $oldValues['requiredVars'] ?? [], + $values['requiredVars'] ?? [] + ); + $this->allFiles[$barename] = array_merge($oldValues, $values); + } + + /** + * Write target files + * + * @return bool + */ + protected function writeTargetFiles(): bool + { + if (!file_exists($this->targetDir)) { + if (!mkdir($this->targetDir, 0o777, true)) { + $this->error("Could not create target directory $this->targetDir"); + return false; + } + } + // If we have a variables file, collect all variables needed by later files and add them: + if ($this->variablesFile) { + $variablesFileIndex = $this->allFiles[$this->variablesFile]['index'] ?? PHP_INT_MAX; + + $allRequiredVars = []; + foreach ($this->allFiles as $filename => &$fileSpec) { + // Check if the file is included before the variables file (if so, we must add the variables in + // that file): + if ($fileSpec['index'] < $variablesFileIndex) { + continue; + } + array_push($allRequiredVars, ...$fileSpec['requiredVars']); + $fileSpec['requiredVars'] = []; + } + unset($fileSpec); + + $this->updateFileCollection( + $this->variablesFile, + [ + 'requiredVars' => $allRequiredVars, + ] + ); + $this->debug(count($allRequiredVars) . " variables added to $this->variablesFile"); + } + + foreach ($this->allFiles as $filename => $fileSpec) { + $fullPath = $this->getTargetFilename($filename); + if (!$this->isInTargetDir($fullPath)) { + continue; + } + $lines = $fileSpec['lines'] ?? []; + + // Add !default to existing variables (unless excluded): + $addDefault = true; + foreach ($this->noDefaultFiles as $noDefaultPattern) { + if (fnmatch($noDefaultPattern, $fullPath)) { + $addDefault = false; + } + } + if ($addDefault) { + foreach ($lines as &$line) { + $line = preg_replace('/(\$.+):(.+);/', '$1:$2 !default;', $line); + } + unset($line); + } + + // Prepend required variables: + if ($fileSpec['requiredVars']) { + $requiredVars = $this->resolveVariableDependencies($fileSpec['requiredVars'], $fileSpec['vars'] ?? []); + $linesToAdd = ['// The following variables were automatically added in SCSS conversion']; + $addedVars = []; + foreach (array_reverse($requiredVars) as $current) { + $var = $current['var']; + if (!in_array($var, $addedVars)) { + $linesToAdd[] = $this->processSubstitutions('', "@$var: $current[value];"); + $addedVars[] = $var; + } + } + + // Prepend new definitions: + $linesToAdd[] = ''; + array_unshift($lines, ...$linesToAdd); + } + // Write the updated file: + if (false === file_put_contents($fullPath, implode(PHP_EOL, $lines)) . PHP_EOL) { + $this->error("Could not write file $fullPath"); + } + $this->debug("Created $fullPath"); + } + + if ($this->enableScss) { + $styleIni = <<targetDir . '/style.ini'; + if (false === file_put_contents($iniFile, $styleIni)) { + $this->error("Could not write file $iniFile"); + } + } + + return true; + } + + /** + * Get target file name + * + * @param string $filename File name + * + * @return string + */ + protected function getTargetFilename(string $filename): string + { + $fullPath = str_replace($this->sourceDir, $this->targetDir, $filename); + if (!str_ends_with($fullPath, '.scss')) { + $fullPath .= '.scss'; + } + return $fullPath; + } + + /** + * Process string substitutions + * + * @param string $filename File name (or empty string when converting variables) + * @param string $contents File contents + * + * @return string + */ + protected function processSubstitutions(string $filename, string $contents): string + { + if ($filename) { + $this->debug("$filename: start processing substitutions", OutputInterface::VERBOSITY_DEBUG); + } else { + $this->debug("Start processing substitutions for '$contents'", OutputInterface::VERBOSITY_DEBUG); + } + foreach ($this->substitutions as $i => $substitution) { + if ($filename) { + $this->debug("$filename: processing substitution $i", OutputInterface::VERBOSITY_DEBUG); + } else { + $this->debug("Processing substitution $i for '$contents'", OutputInterface::VERBOSITY_DEBUG); + } + if (str_starts_with($substitution['pattern'], '/')) { + // Regexp + if (is_string($substitution['replacement'])) { + $contents = preg_replace($substitution['pattern'], $substitution['replacement'], $contents); + } else { + $contents = preg_replace_callback( + $substitution['pattern'], + $substitution['replacement'], + $contents + ); + } + if (null === $contents) { + throw new \Exception( + "Failed to process regexp substitution $i: " . $substitution['pattern'] + . ': ' . preg_last_error_msg() + ); + } + } else { + // String + $contents = str_replace($substitution['pattern'], $substitution['replacement'], $contents); + } + } + + if ($filename) { + $this->debug("$filename: done processing substitutions", OutputInterface::VERBOSITY_DEBUG); + } else { + $this->debug("Done processing substitutions for '$contents'", OutputInterface::VERBOSITY_DEBUG); + } + + return $contents; + } + + /** + * Get substitutions + * + * @return array; + */ + protected function getSubstitutions(): array + { + if ($localConfigFile = $this->pathResolver->getLocalConfigPath('lessToScss.config.php')) { + $this->debug("Using local config file $localConfigFile", OutputInterface::VERBOSITY_DEBUG); + $config = include $localConfigFile; + } else { + $configFile = dirname(__FILE__) . '/../../../../config/lessToScss.config.php'; + $this->debug("Using shared config file $configFile", OutputInterface::VERBOSITY_DEBUG); + $config = include $configFile; + } + return $config['substitutions']; + } + + /** + * Output a debug message + * + * @param string $msg Message + * @param int $verbosity Verbosity level + * + * @return void + */ + protected function debug(string $msg, int $verbosity = OutputInterface::VERBOSITY_VERBOSE): void + { + $this->output->writeln($msg, $verbosity); + } + + /** + * Output an error message + * + * @param string $msg Message + * + * @return void + */ + protected function error(string $msg): void + { + if ($this->output) { + $this->output->writeln('' . OutputFormatter::escape($msg) . ''); + } + } + + /** + * Output a warning message + * + * @param string $msg Message + * + * @return void + */ + protected function warning(string $msg): void + { + if ($this->output) { + $this->output->writeln('' . OutputFormatter::escape($msg) . ''); + } + } + + /** + * Check if file name points to a readable file + * + * @param string $filename File name + * + * @return bool + */ + protected function isReadableFile(string $filename): bool + { + return file_exists($filename) && (is_file($filename) || is_link($filename)); + } +} diff --git a/module/FinnaConsole/src/FinnaConsole/Command/Util/LessToScssCommandFactory.php b/module/FinnaConsole/src/FinnaConsole/Command/Util/LessToScssCommandFactory.php new file mode 100644 index 00000000000..ece405b248c --- /dev/null +++ b/module/FinnaConsole/src/FinnaConsole/Command/Util/LessToScssCommandFactory.php @@ -0,0 +1,70 @@ + + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link http://vufind.org/wiki/vufind2:developer_manual Wiki + */ + +namespace FinnaConsole\Command\Util; + +use Laminas\ServiceManager\Exception\ServiceNotCreatedException; +use Laminas\ServiceManager\Exception\ServiceNotFoundException; +use Laminas\ServiceManager\Factory\FactoryInterface; +use Psr\Container\ContainerExceptionInterface as ContainerException; +use Psr\Container\ContainerInterface; + +/** + * Factory for the "less to sass" task. + * + * @category VuFind + * @package Service + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link http://vufind.org/wiki/vufind2:developer_manual Wiki + */ +class LessToScssCommandFactory 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 + ) { + return new $requestedName($container->get(\VuFind\Config\PathResolver::class)); + } +} diff --git a/themes/custom/less/finna.less b/themes/custom/less/finna.less index 8caa6e8060f..9b123afc96c 100644 --- a/themes/custom/less/finna.less +++ b/themes/custom/less/finna.less @@ -10,9 +10,6 @@ // Finna Bootstrap variable overrides @import "../../finna2/scss/global/bootstrap-variable-overrides"; -// Custom scss -@import "custom.scss"; - // Font Awesome (loaded via include path to silence warnings from dependencies) @import "font-awesome/font-awesome"; @@ -31,6 +28,9 @@ @import "../../finna2/scss/finna-other"; @import "../../finna2/scss/components"; +// Custom scss +@import "custom.scss"; + <#SCSS */ /* #LESS> */ @import "../../finna2/less/finna.less"; diff --git a/themes/custom/scss/finna.scss b/themes/custom/scss/finna.scss index 4a3805470b4..8a192e4f1ef 100644 --- a/themes/custom/scss/finna.scss +++ b/themes/custom/scss/finna.scss @@ -10,9 +10,6 @@ // Finna Bootstrap variable overrides @import "../../finna2/scss/global/bootstrap-variable-overrides"; -// Custom scss -@import "custom.scss"; - // Font Awesome (loaded via include path to silence warnings from dependencies) @import "font-awesome/font-awesome"; @@ -31,5 +28,8 @@ @import "../../finna2/scss/finna-other"; @import "../../finna2/scss/components"; +// Custom scss +@import "custom.scss"; + /* <#SCSS */ diff --git a/themes/finna2/scss/finna/feed.scss b/themes/finna2/scss/finna/feed.scss index 8f3d6faf4cb..c7d79da8147 100644 --- a/themes/finna2/scss/finna/feed.scss +++ b/themes/finna2/scss/finna/feed.scss @@ -982,7 +982,7 @@ finna-feed.carousel-slider.splide--ltr { @container (max-width: $slider-stacked-breakpoint) { finna-feed.carousel-slider.splide--ltr { .feed-item-holder[style] { - height: var(--height !important); + height: var(--height) !important; } .feed-link { flex-direction: column;