diff --git a/bs_lib.services.yml b/bs_lib.services.yml index 7ce0085eb34e06f56a934d5b37aac13625905ab7..4fcfcabeb93f4cb8c51895b7f14cdfd69b0a2cf6 100644 --- a/bs_lib.services.yml +++ b/bs_lib.services.yml @@ -1,3 +1,5 @@ services: bs_lib.svg_tools: class: Drupal\bs_lib\SvgTools + bs_lib.theme_tools: + class: Drupal\bs_lib\ThemeTools diff --git a/src/Commands/BsLibCommands.php b/src/Commands/BsLibCommands.php index 5ca4040db79c6d622b489503b4afeb0eb3d381b2..03f02343859b7217d2f4c946cdf0851882a838ff 100644 --- a/src/Commands/BsLibCommands.php +++ b/src/Commands/BsLibCommands.php @@ -2,9 +2,9 @@ namespace Drupal\bs_lib\Commands; +use Drupal\bs_lib\ThemeTools; use Drupal\Component\Serialization\Yaml; -use Drupal\Core\Extension\ExtensionDiscovery; -use Drupal\Core\Extension\ThemeHandlerInterface; +use Symfony\Component\Routing\Generator\UrlGenerator; use Drush\Commands\DrushCommands; use Drush\Drush; use Psr\Log\LogLevel; @@ -16,15 +16,6 @@ use stdClass; */ class BsLibCommands extends DrushCommands { - /** - * BsLibCommands constructor. - */ - public function __construct(ThemeHandlerInterface $theme_handler) { - // For easier maintenance we just wrap the methods from bs_base here. - $bs_base = $theme_handler->getTheme('bs_base'); - include_once "{$bs_base->getPath()}/bs_base.drush.inc"; - } - /** * Create a new bs_base compatible child theme. * @@ -41,7 +32,7 @@ class BsLibCommands extends DrushCommands { * @usage drush bs-tc bs_bootstrap custom_theme 'Custom theme' 'Custom theme description' * Create a new bs_base compatible child theme. * - * @throws \Exception + * @throws Exception */ public function themeCreate($parent_machine_name, $child_machine_name, $child_name, $child_description) { // Verify that the child machine name contains no disallowed characters. @@ -51,14 +42,17 @@ class BsLibCommands extends DrushCommands { $this->output()->writeln("Starting $child_machine_name theme creation"); + /** @var ThemeTools $theme_tools */ + $theme_tools = \Drupal::service('bs_lib.theme_tools'); + // Parent theme should exist. - $parent_path = $this->drupalGetThemePath($parent_machine_name); + $parent_path = $theme_tools->drupalGetThemePath($parent_machine_name); if (empty($parent_path)) { throw new \Exception('Parent theme does not exist.'); } // Child theme should not exist. - if (!empty($child_path = $this->drupalGetThemePath($child_machine_name))) { + if (!empty($child_path = $theme_tools->drupalGetThemePath($child_machine_name))) { throw new \Exception("Child theme already exist on $child_path file system."); } @@ -74,7 +68,7 @@ class BsLibCommands extends DrushCommands { // For multi sites we will use multisite folder. $child_path = $site_path . '/themes/custom/' . $child_machine_name; } - drush_log("Creating {$child_path} folder.", LogLevel::INFO); + Drush::logger()->log(LogLevel::INFO, "Creating {$child_path} folder."); if (!mkdir($child_path, 0755, TRUE)) { throw new \Exception("Failed to create child theme directory on $child_path path."); } @@ -115,7 +109,7 @@ class BsLibCommands extends DrushCommands { $this->generateFile('README.md', $options); // Rebuild themes static cache because new theme is created. - $this->drupalThemeListInfo(TRUE); + $theme_tools->drupalThemeListInfo(TRUE); // Make sure we are on latest parent theme versions. $update_functions = $this->GetUpdateHooks($child_machine_name); @@ -128,7 +122,7 @@ class BsLibCommands extends DrushCommands { $bs_versions['bs_versions.' . $theme_name] = (int) $last_function; } - $all_themes = $this->drupalThemeListInfo(); + $all_themes = $theme_tools->drupalThemeListInfo(); $this->setYmlValue($all_themes[$child_machine_name]->pathname, $bs_versions, TRUE); } @@ -150,12 +144,16 @@ class BsLibCommands extends DrushCommands { * * @usage drush bs-tu custom_theme * Create a new bs_base compatible child theme. - * @throws \Exception + * + * @throws Exception */ public function themeUpdate($target_machine_name) { $this->output()->writeln("Updating a $target_machine_name theme"); - $target_path = $this->drupalGetThemePath($target_machine_name); + /** @var ThemeTools $theme_tools */ + $theme_tools = \Drupal::service('bs_lib.theme_tools'); + + $target_path = $theme_tools->drupalGetThemePath($target_machine_name); if (empty($target_path)) { throw new \Exception('Target theme does not exist.'); } @@ -168,8 +166,8 @@ class BsLibCommands extends DrushCommands { // Run update hooks. $this->themeRunUpdateHooks($target_machine_name); - $all_themes = $this->drupalThemeListInfo(); - $first_parent_machine_name = $this->drupalGetParentThemeName($target_machine_name); + $all_themes = $theme_tools->drupalThemeListInfo(); + $first_parent_machine_name = $theme_tools->drupalGetParentThemeName($target_machine_name); $this->updateSassFiles($target_machine_name); @@ -214,11 +212,16 @@ class BsLibCommands extends DrushCommands { * * @usage drush bs-tb custom_theme * Download custom_theme build dependencies and build all assets. + * + * @throws Exception */ public function themeBuild($theme_machine_name) { $this->output()->writeln("Building asset for a $theme_machine_name theme"); - $target_path = $this->drupalGetThemePath($theme_machine_name); + /** @var ThemeTools $theme_tools */ + $theme_tools = \Drupal::service('bs_lib.theme_tools'); + + $target_path = $theme_tools->drupalGetThemePath($theme_machine_name); if (empty($target_path)) { throw new \Exception("Target theme {$theme_machine_name} does not exist."); } @@ -250,8 +253,50 @@ class BsLibCommands extends DrushCommands { * @return array * Array of unique lines. */ - protected function arrayUniqueLines(array $lines) { - return _bs_base_array_unique_lines($lines); + protected function arrayUniqueLines(array $lines): array { + $unique_lines = array(); + $matches = []; + + foreach ($lines as $line) { + // Empty line we just add and continue. + if ($line === "\n") { + $unique_lines[] = $line; + continue; + } + + // Get import path parts. + $res = FALSE; + if (preg_match("#(//|/\*)?\s*@import\s*['\"]?(.*)/(.*)(\.scss)?['\"]#", $line, $matches)) { + // If import path is not in unique lines then lets add it. We check partial + // name with `_` and without `_` character on the start. + $pattern = "#@import\s*['\"]{$matches[2]}/_?{$matches[3]}(\.scss)?['\"]#"; + $res = preg_grep($pattern, $unique_lines); + } + + if (empty($res)) { + // Compare line comment variation. + // @todo - this comparator is weak and will only work for simple comment + // case. If more is needed we will need to use some kind of regular + // expression. + if (!str_starts_with($line, '//')) { + $comment_line = '//' . $line; + if (in_array($comment_line, $lines)) { + // If it exists we will save commented version. + $unique_lines[] = $comment_line; + continue; + } + } + $unique_lines[] = $line; + } + else { + // When we have a match we will override previous line because match can + // happen with comment and without comment, and new line is always the + // strongest. + $unique_lines[key($res)] = $line; + } + } + + return $unique_lines; } /** @@ -265,19 +310,51 @@ class BsLibCommands extends DrushCommands { * - child_name * - child_description. */ - protected function copyThemeFiles(array $options) { - _bs_base_copy_theme_files($options); + protected function copyThemeFiles(array $options): void { + // Single files and folders which we will copy if they exist in parent theme. + $files = [ + 'config', + 'config/install', + 'config/install/' . $options['parent_machine_name'] . '.settings.yml', + 'config/schema', + 'fonts', + 'templates', + 'templates/README.md', + '.browserslistrc', + '.fantasticonrc.js', + '.gitignore', + '.npmrc', + '.nvmrc', + 'favicon.ico', + 'gulp-tasks.js', + 'gulpfile.js', + 'logo.svg', + 'package.json', + 'screenshot.png', + ]; + foreach ($files as $file) { + $this->copyFile($file, $options); + } + + // Recursive copy of folders from parent to child theme. + $recursive_copy = [ + 'fantasticon', + 'images/font-icons', + ]; + foreach ($recursive_copy as $folder) { + $this->recursiveCopy($folder, $options); + } } /** - * Copy file from parent theme to child theme. + * Copy folder and it content recursively from parent theme to child theme. * * If parent file is a directory then it will be created. * - * Target subdirectories of a file will be created automatically if they do + * Target subdirectories of a folder will be created automatically if they do * not exist. * - * @param string $file + * @param string $folder * File name. * @param array $options * Array of options having next keys: @@ -287,100 +364,140 @@ class BsLibCommands extends DrushCommands { * - child_path. * * @return bool - * TRUE if the file was copied, FALSE otherwise. + * TRUE if the folder was copied, FALSE otherwise. */ - protected function copyFile($file, array $options) { - return _bs_base_copy_file($file, $options); - } + protected function recursiveCopy(string $folder, array $options): bool { + // If source file does not exist, log a warning and return. + if (!file_exists($options['parent_path'] . '/' . $folder)) { + Drush::logger()->log(LogLevel::WARNING, "Source folder $folder in {$options['parent_path']} does not exist, can not copy."); + return FALSE; + } - /** - * Finds all the base themes for the specified theme. - * - * @param array $themes - * An array of available themes. - * @param string $theme - * The name of the theme whose base we are looking for. - * - * @return array - * Returns an array of all the theme's ancestors including specified theme. - */ - protected function drupalGetBaseThemes(array $themes, $theme) { - return _bs_base_drupal_get_base_themes($themes, $theme); - } + // In the case when folder has directories in it make sure that all + // subdirectories exists in target before doing actual file copy. + if (!$this->ensureDirectory($folder, $options['child_path'])) { + return FALSE; + } - /** - * Returns the first parent theme of passed child theme. - * - * @param string $theme_name - * The name of the child theme whose first parent theme we are looking for. - * - * @return string|NULL - * Returns a theme machine name of first parent theme or NULL if parent does - * not exist. - */ - protected function drupalGetParentThemeName($theme_name) { - return _bs_base_drupal_get_parent_theme_name($theme_name); - } + if (!$this->copyFolder($options['parent_path'] . '/' . $folder, $options['child_path'] . '/' . $folder)) { + Drush::logger()->log(LogLevel::ERROR, "Failed to copy $folder folder from {$options['parent_path']} to {$options['child_path']}."); + return FALSE; + } - /** - * Returns the path to a Drupal theme. - * - * @param string $name - * Theme machine name. - * - * @return string - * The path to the requested theme or an empty string if the item is not - * found. - */ - protected function drupalGetThemePath($name) { - return _bs_base_drupal_get_theme_path($name); + return TRUE; } /** - * Discovers available extensions of a given type. + * Copy file from parent theme to child theme. * - * For an explanation of how this work see ExtensionDiscovery::scan(). + * If parent file is a directory then it will be created. + * + * Target subdirectories of a file will be created automatically if they do not + * exist. * - * @param string $type - * The extension type to search for. One of 'profile', 'module', 'theme', or - * 'theme_engine'. - * @param bool $reset - * Reset internal cache. + * @param string $file + * File name. + * @param array $options + * Array of options having next keys: + * - parent_machine_name + * - parent_path + * - child_machine_name + * - child_path. * - * @return stdClass[] - * An associative array of stdClass objects, keyed by extension name. + * @return bool + * TRUE if the file was copied, FALSE otherwise. */ - protected function drupalScan($type, $reset = FALSE) { - return _bs_base_drupal_scan($type, $reset); + protected function copyFile(string $file, array $options): bool { + // Do file rename if needed. + $new_file_name = str_replace($options['parent_machine_name'], $options['child_machine_name'], $file); + + // If source file does not exist, log a warning and return. + if (!file_exists($options['parent_path'] . '/' . $file)) { + Drush::logger()->log(LogLevel::WARNING, "Source file $file in {$options['parent_path']} does not exist, can not copy."); + return FALSE; + } + + // If target file exist just return. + if (file_exists($options['child_path'] . '/' . $new_file_name)) { + return FALSE; + } + + // If parent file is directory then create directory. + if (is_dir($options['parent_path'] . '/' . $file)) { + return mkdir($options['child_path'] . '/' . $new_file_name, 0755); + } + + // In the case when file name has directory in it make sure that all + // subdirectories exists in target before doing actual file copy. + if (!$this->ensureDirectory($new_file_name, $options['child_path'])) { + return FALSE; + } + + // Copy file from parent theme folder to child theme folder. + if (!copy($options['parent_path'] . '/' . $file, $options['child_path'] . '/' . $new_file_name)) { + Drush::logger()->log(LogLevel::ERROR, "Failed to copy $file file from {$options['parent_path']} to {$options['child_path']}."); + return FALSE; + } + + return TRUE; } /** - * Recursively scans a base directory for the extensions it contains. + * Recursive copy of source folder to the destination. * - * @see ExtensionDiscovery::scanDirectory() - * For an explanation of how this works. + * The function checks if the source and destination directories exist, and + * creates the destination directory if it doesn't. Then it opens the source + * directory, iterates through its contents, and for each file it finds, + * it recursively calls itself if the file is a directory, or copies the file to + * the destination if it's a regular file. * - * @param string $dir - * A relative base directory path to scan, without trailing slash. + * @param string $source + * Path to the folder to be copied. + * @param string $destination + * Path to the destination folder where the source folder should be copied to. * - * @return stdClass[] - * An associative array of stdClass objects, keyed by extension name. - */ - protected function drupalScanDirectory($dir) { - return _bs_base_drupal_scan_directory($dir); - } + * @return bool + * TRUE if the folder was copied, FALSE otherwise. + */ + protected function copyFolder(string $source, string $destination): bool { + // Check if source exists. + if (!is_dir($source)) { + return FALSE; + } - /** - * Get information's for all themes. - * - * @param bool $reset - * Reset internal cache. - * - * @return array - * Array holding themes information's. - */ - protected function drupalThemeListInfo($reset = FALSE) { - return _bs_base_drupal_theme_list_info($reset); + // Check if destination exists. If not, create it. + if (!is_dir($destination)) { + mkdir($destination); + } + + // Open the source directory and iterate through its contents. + $dir = opendir($source); + while (($file = readdir($dir)) !== FALSE) { + // Skip special files "." and ".." + if ($file == '.' || $file == '..') { + continue; + } + + $sourcePath = $source . '/' . $file; + $destinationPath = $destination . '/' . $file; + // If the file is a directory, do a recursive call. + if (is_dir($sourcePath)) { + $this->copyFolder($sourcePath, $destinationPath); + } + // Skip existing files. + elseif (file_exists($destinationPath)) { + continue; + } + // If not exist copy the file. + else { + copy($sourcePath, $destinationPath); + } + } + + // Close the source directory + closedir($dir); + + return TRUE; } /** @@ -392,8 +509,13 @@ class BsLibCommands extends DrushCommands { * @return array * Returns an array of all the parent themes. */ - protected function getParentThemes($theme_machine_name) { - return _bs_base_get_parent_themes($theme_machine_name); + protected function getParentThemes(string $theme_machine_name): array { + /** @var ThemeTools $bs_lib_theme */ + $theme_tools = \Drupal::service('bs_lib.theme_tools'); + $all_themes = $theme_tools->drupalThemeListInfo(); + $parent_themes = $theme_tools->drupalGetBaseThemes($all_themes, $theme_machine_name); + array_pop($parent_themes); + return $parent_themes; } /** @@ -405,8 +527,9 @@ class BsLibCommands extends DrushCommands { * @return mixed|null * Theme info object or NULL if theme does not exist. */ - protected function getThemeInfo($theme_machine_name) { - return _bs_base_get_theme_info($theme_machine_name); + protected function getThemeInfo(string $theme_machine_name): mixed { + $all_themes = \Drupal::service('bs_lib.theme_tools')->drupalThemeListInfo(); + return $all_themes[$theme_machine_name] ?? NULL; } /** @@ -421,8 +544,24 @@ class BsLibCommands extends DrushCommands { * Array of all child themes machine names. Empty array if child themes does * not exist. */ - protected function findChildThemes($parent_theme) { - return _bs_base_find_child_themes($parent_theme); + protected function findChildThemes(string $parent_theme): array { + $child_themes = []; + + /** @var ThemeTools $bs_lib_theme */ + $bs_lib_theme = \Drupal::service('bs_lib.theme_tools'); + + $themes = $bs_lib_theme->drupalThemeListInfo(); + foreach ($themes as $theme => $theme_info) { + if ($theme === $parent_theme) { + continue; + } + $parent_themes = $bs_lib_theme->drupalGetBaseThemes($themes, $theme); + if (isset($parent_themes[$parent_theme])) { + $child_themes[$theme] = $theme; + } + } + + return $child_themes; } /** @@ -436,8 +575,16 @@ class BsLibCommands extends DrushCommands { * @return bool * TRUE if library is coming from parents, FALSE other way. */ - protected function libraryKeyComingFromParents($library_key, $theme_machine_name) { - return _bs_base_library_key_coming_from_parents($library_key, $theme_machine_name); + protected function libraryKeyComingFromParents(string $library_key, string $theme_machine_name): bool { + // Add bs_lib module to the parents array. + $parent_themes = ['bs_lib' => 'bs_lib'] + $this->getParentThemes($theme_machine_name); + foreach ($parent_themes as $parent_theme_machine_name => $parent_theme_label) { + if (str_starts_with($library_key, $parent_theme_machine_name)) { + return TRUE; + } + } + + return FALSE; } /** @@ -451,10 +598,36 @@ class BsLibCommands extends DrushCommands { * - child_name * - child_description. * - * @throws \Exception + * @throws Exception */ - protected function reconfigureThemeFiles(array $options) { - _bs_base_reconfigure_theme_files($options); + protected function reconfigureThemeFiles(array $options): void { + // Yaml value replaces. + $yaml_files = [ + 'config/install/' . $options['child_machine_name'] . '.settings.yml' => [ + 'logo.path' => $options['child_path'] . '/logo.svg', + 'favicon.path' => $options['child_path'] . '/favicon.ico', + ], + ]; + foreach ($yaml_files as $file => $value) { + $this->setYmlValue($options['child_path'] . '/' . $file, $value); + } + + // Regexp string replacements. + $regexp_files = [ + 'gulpfile.js' => [ + $options['parent_machine_name'] => $options['child_machine_name'], + ], + 'package.json' => [ + "\"name\":\s*\"{$options['parent_machine_name']}\"" => "\"name\": \"{$options['child_machine_name']}\"", + "\"description\":\s*\".*\"" => "\"description\": \"{$options['child_description']}\"", + ], + ]; + foreach ($regexp_files as $file => $regexps) { + $file_name = $options['child_path'] . '/' . $file; + if (!\Drupal::service('bs_lib.theme_tools')->regexpFile($file_name, $regexps)) { + Drush::logger()->log(LogLevel::WARNING, "Can not process file $file_name for regexp search&replace."); + } + } } /** @@ -466,8 +639,48 @@ class BsLibCommands extends DrushCommands { * @return array * Array of update functions. */ - protected function getUpdateHooks($target_machine_name) { - return _bs_base_get_update_hooks($target_machine_name); + protected function getUpdateHooks(string $target_machine_name): array { + $update_functions = []; + $all_themes = \Drupal::service('bs_lib.theme_tools')->drupalThemeListInfo(); + + // Get current parents version information for this theme. If not defined this + // means that we will run all existing update functions. + $bs_versions = $all_themes[$target_machine_name]->info['bs_versions'] ?? []; + + // Get parent themes. + $parent_themes = $this->getParentThemes($target_machine_name); + + // Cycle through all parent themes and find update functions. + foreach (array_keys($parent_themes) as $parent_theme) { + // If install file exist get all update functions from it. + $install_filepath = $all_themes[$parent_theme]->subpath . '/' . $parent_theme . '.bs_base.install'; + + if (file_exists($install_filepath)) { + $content = file_get_contents($install_filepath); + + // Find update functions and first comment line. + if (preg_match_all('/\s\*\s(.*?)\n\s\*\/\nfunction\s' . $parent_theme . '_bs_update_(\d{4})/', $content, $matches)) { + $functions = array_combine($matches[2], $matches[1]); + + // Filter update functions that were run in the past. + if (isset($bs_versions[$parent_theme])) { + $parent_theme_version = (int) $bs_versions[$parent_theme]; + $functions = array_filter($functions, function ($version) use ($parent_theme_version) { + return (int) $version > $parent_theme_version; + }, ARRAY_FILTER_USE_KEY); + } + + if (!empty($functions)) { + $update_functions[$parent_theme] = [ + 'file' => $install_filepath, + 'functions' => $functions, + ]; + } + } + } + } + + return $update_functions; } /** @@ -476,12 +689,12 @@ class BsLibCommands extends DrushCommands { * @param string $target_machine_name * Target theme machine name. * - * @throws \Exception + * @throws Exception */ - protected function themeRunUpdateHooks($target_machine_name) { + protected function themeRunUpdateHooks(string $target_machine_name): void { $update_functions = $this->getUpdateHooks($target_machine_name); if (empty($update_functions)) { - $this->output()->writeln("No theme updates required."); + $this->output()->writeln('No theme updates required.'); return; } @@ -519,7 +732,7 @@ class BsLibCommands extends DrushCommands { } // Update info file with the latest versions. - $all_themes = $this->drupalThemeListInfo(); + $all_themes = \Drupal::service('bs_lib.theme_tools')->drupalThemeListInfo(); $this->setYmlValue($all_themes[$target_machine_name]->pathname, $bs_versions, TRUE); } @@ -529,8 +742,12 @@ class BsLibCommands extends DrushCommands { * @param string $path * Path to theme folder. */ - protected function drushBuild($path) { - _bs_base_drush_build($path); + protected function drushBuild(string $path): void { + // Install npm packages and execute gulp sass compilation. + Drush::logger()->log(LogLevel::INFO, "Installing any missing package and rebuilding assets"); + // Run build-install first time, so we are sure that npm-run-all is installed + // which is needed for `npm-run-all`. + exec('cd ' . $path . ' && npm run build-install && npm run build'); } /** @@ -544,8 +761,17 @@ class BsLibCommands extends DrushCommands { * @return bool * TRUE on success, FALSE on error. */ - protected function ensureDirectory($filename, $path) { - return _bs_base_ensure_directory($filename, $path); + protected function ensureDirectory(string $filename, string $path): bool { + // In the case when file name has directory in it make sure that all + // subdirectories exists in target before doing actual file copy. + $dirname = dirname($filename); + if ($dirname && !is_dir($path . '/' . $dirname)) { + if (!mkdir($path . '/' . $dirname, 0755, TRUE)) { + Drush::logger()->log(LogLevel::ERROR, "Failed to create copy target directory $dirname."); + return FALSE; + } + } + return TRUE; } /** @@ -571,16 +797,67 @@ class BsLibCommands extends DrushCommands { * extension. Defaults to 'uri'. * - 'min_depth': Minimum depth of directories to return files from. Defaults * to 0. - * @param int $depth - * The current depth of recursion. This parameter is only used internally and - * should not be passed in. * - * @return + * @return array * An associative array (keyed on the chosen key) of objects with 'uri', * 'filename', and 'name' properties corresponding to the matched files. */ - protected function fileScanDirectory($dir, $mask, $options = [], $depth = 0) { - return _bs_base_file_scan_directory($dir, $mask, $options, $depth); + protected function fileScanDirectory(string $dir, string $mask, array $options = [], int $depth = 0): array { + // Merge in defaults. + $options += [ + 'callback' => 0, + 'recurse' => TRUE, + 'key' => 'uri', + 'min_depth' => 0, + ]; + // Normalize $dir only once. + if ($depth == 0) { + $dir_has_slash = (str_ends_with($dir, '/')); + } + + $options['key'] = in_array($options['key'], ['uri', 'filename', 'name']) ? $options['key'] : 'uri'; + $files = []; + // Avoid warnings when opendir does not have the permissions to open a + // directory. + if (is_dir($dir)) { + if ($handle = @opendir($dir)) { + while (FALSE !== ($filename = readdir($handle))) { + // Skip this file if it matches the nomask or starts with a dot. + if ($filename[0] != '.' + && !(isset($options['nomask']) && preg_match($options['nomask'], $filename)) + ) { + if ($depth == 0 && $dir_has_slash) { + $uri = "$dir$filename"; + } + else { + $uri = "$dir/$filename"; + } + if ($options['recurse'] && is_dir($uri)) { + // Give priority to files in this folder by merging them in after + // any subdirectory files. + $files = array_merge($this->fileScanDirectory($uri, $mask, $options, $depth + 1), $files); + } + elseif ($depth >= $options['min_depth'] && preg_match($mask, $filename)) { + // Always use this match over anything already set in $files with + // the same $options['key']. + $file = new stdClass(); + $file->uri = $uri; + $file->filename = $filename; + $file->name = pathinfo($filename, PATHINFO_FILENAME); + $key = $options['key']; + $files[$file->$key] = $file; + if ($options['callback']) { + $options['callback']($uri); + } + } + } + } + + closedir($handle); + } + } + + return $files; } /** @@ -600,8 +877,175 @@ class BsLibCommands extends DrushCommands { * @return array * Returns array of flattened SASS @import directives. */ - protected function flattenSassFileImports($target_sass_file, $target_machine_name, array $current_themes, array $parent_themes_sass_files, $depth = 0) { - return _bs_base_flatten_sass_file_imports($target_sass_file, $target_machine_name, $current_themes, $parent_themes_sass_files, $depth); + protected function flattenSassFileImports(object $target_sass_file, string $target_machine_name, array $current_themes, array $parent_themes_sass_files, int $depth = 0): array { + $flatten_lines = []; + + $lines = file($target_sass_file->uri); + if ($lines === FALSE) { + Drush::logger()->log(LogLevel::ERROR, "Failed to open {$target_sass_file->uri} file."); + return []; + } + + // For the depth 0 even when we are already flattened (no direct imports to + // parent main SASS file) we still need to check parent theme SASS file in + // case that parent files have additional changes that are not in target + // files. + $check_parent = FALSE; + if ($depth === 0) { + $check_parent = TRUE; + } + else { + // For the depth greater than 0 we will check parent theme only if it has + // direct import. + $parent_imports = []; + foreach (array_keys($parent_themes_sass_files) as $parent_theme) { + $parent_imports = array_merge($parent_imports, preg_grep("#^@import\s*['\"]{$parent_theme}((?!/partials/).)*['\"]#", $lines)); + } + if (!empty($parent_imports)) { + $check_parent = TRUE; + } + } + if ($check_parent) { + $additional_lines = []; + end($parent_themes_sass_files); + $first_parent_theme = key($parent_themes_sass_files); + foreach ($parent_themes_sass_files[$first_parent_theme] as $file_path => $file) { + if ($target_sass_file->filepath === $file->filepath) { + // Remove 'file-name.scss' part from a file path. + $sass_import_path = substr($file->filepath, 0, -strlen($file->filename)); + // This patter will pick all possible import variation we need, with + // partial sign and without, with .scss extension and without, commented + // or not commented: + // For example all next cases are considered positive for us: + // @import "parent_theme/sass/theme/print"; + // @import "parent_theme/sass/theme/print.scss"; + // @import "parent_theme/sass/theme/_print"; + // @import "parent_theme/sass/theme/_print.scss"; + // //@import "parent_theme/sass/theme/print"; + // // and all other // or /* combinations. + $pattern = "#(?://|/\*)?\s*@import\s*['\"]{$first_parent_theme}/{$sass_import_path}_?{$file->name}(\.scss)?['\"]#"; + + // Add additional parent imports only if they already don't exist in + // current lines. + if (empty(preg_grep($pattern, $lines))) { + $rest_of_parent_themes = $parent_themes_sass_files; + array_pop($rest_of_parent_themes); + $additional_flattened_files = $this->flattenSassFileImports($file, $first_parent_theme, $current_themes, $rest_of_parent_themes, $depth + 1); + $additional_lines = $this->arrayUniqueLines(array_merge($additional_lines, $additional_flattened_files)); + } + break; + } + } + // If there are new additional lines from parent themes we need to check + // them also. This will produce duplicate lines, so we need to take that + // into consideration. + if (!empty($additional_lines)) { + // If first line is init the lets add parent imports after it. + if (str_starts_with($lines[0], '@import "init"')) { + // Eliminate duplicate lines and comment duplicate lines. + $lines = $this->arrayUniqueLines(array_merge([array_shift($lines)], $additional_lines, $lines)); + } + else { + $lines = $this->arrayUniqueLines(array_merge($additional_lines, $lines)); + } + } + } + + foreach ($lines as $line) { + if ($depth === 0) { + // If it does not start with @import just add it and move on. + if (!str_starts_with($line, '@import')) { + $flatten_lines[] = $line; + continue; + } + } + // Skip empty lines, and commented lines from parent theme SASS files that + // are NOT @import directives. + elseif ($depth > 0 && (empty($line) || (str_starts_with($line, '//') && !str_contains($line, '@import')))) { + continue; + } + + // Find and flatten @import directives. + if (preg_match("#^(//|/\*)?\s*(@import\s+['\"])([a-zA-Z0-9_@]+)(.*?)['\"]#", $line, $matches)) { + $sass_file_commented = !empty($matches[1]); + $sass_file_context = $matches[3]; + $sass_file_path_part = $matches[4]; + + if (isset($parent_themes_sass_files[$sass_file_context]) && !empty($sass_file_path_part)) { + // Append scss extension. + if (!str_contains($sass_file_path_part, '.scss')) { + $sass_file_path_part .= '.scss'; + } + + $sass_file_path = DRUPAL_ROOT . '/' . $current_themes[$sass_file_context]->subpath . $sass_file_path_part; + + if (isset($parent_themes_sass_files[$sass_file_context][$sass_file_path])) { + // If this @import partial directive has '_' in the path file name + // then this is a parent partial and in this case just c&p the line. + $sass_file_path_info = pathinfo($sass_file_path); + if (str_starts_with($sass_file_path_info['basename'], '_')) { + $flatten_lines[] = $line; + } + // If this is not a parent partial then lets import it. + else { + $new_flattened_files = $this->flattenSassFileImports($parent_themes_sass_files[$sass_file_context][$sass_file_path], $sass_file_context, $current_themes, $parent_themes_sass_files, $depth + 1); + $flatten_lines = $this->arrayUniqueLines(array_merge($flatten_lines, $new_flattened_files)); + } + } + else { + // If file does not exist check that it is not maybe a partial. + $sass_partial_file_path_part = explode('/', $sass_file_path_part); + $sass_partial_file_path_part[count($sass_partial_file_path_part) - 1] = '_' . $sass_partial_file_path_part[count($sass_partial_file_path_part) - 1]; + $sass_partial_file_path_part = implode('/', $sass_partial_file_path_part); + $sass_file_path = DRUPAL_ROOT . '/' . $current_themes[$sass_file_context]->subpath . $sass_partial_file_path_part; + + if (isset($parent_themes_sass_files[$sass_file_context][$sass_file_path])) { + // @todo - Check for duplicates including commented duplicate for + // $depth > 0? + $flatten_lines[] = $line; + } + } + } + else { + if ($depth > 0) { + // Skip @import "init"; lines from parent themes. + if ($sass_file_context === 'init' && empty($sass_file_path)) { + continue; + } + // Expand the import for all partials from these themes. + elseif ($sass_file_context === 'partials') { + // Get the sub-folder sass part of the target file. + $subfolder = NULL; + $parts = explode($current_themes[$target_machine_name]->subpath, $target_sass_file->uri); + if (!empty($parts)) { + $parts = explode('/', $parts[1]); + array_pop($parts); + $subfolder = implode('/', $parts); + } + + if (empty($subfolder)) { + Drush::logger()->log(LogLevel::ERROR, "Sub folder is empty and it is needed for $line."); + continue; + } + $line = ($sass_file_commented ? '//' : '') . "@import \"{$target_machine_name}{$subfolder}/{$sass_file_context}{$sass_file_path_part}\";\n"; + } + } + + // @todo - Check for duplicates including commented duplicate? + $flatten_lines[] = $line; + } + } + else { + // If we land here, and we are target theme ($depth == 0) then something + // is probably wrong lets report it. + // If we are in some parent theme ($depth > 0) then we ignore this line. + if ($depth === 0) { + Drush::logger()->log(LogLevel::ERROR, "Parsing of SASS file $target_sass_file->uri failed for some reason for line $line\n"); + } + } + } + + return $flatten_lines; } /** @@ -612,11 +1056,21 @@ class BsLibCommands extends DrushCommands { * @param array $options * Array of parent/child options. * - * @return bool - * TRUE on success, FALSE other way. + * @return string|bool + * String of SASS import path on success, FALSE other way. */ - protected function generateSassFile($parent_sass_file, array $options) { - return _bs_base_generate_sass_file($parent_sass_file, $options); + protected function generateSassFile(object $parent_sass_file, array $options): string|bool { + $filepath = $options['child_path'] . '/' . $parent_sass_file->filepath; + $this->ensureDirectory($parent_sass_file->filepath, $options['child_path']); + + $sass_import_path = $options['parent_machine_name'] . '/' . $parent_sass_file->filepath; + $sass_import_path = substr($sass_import_path, 0, strpos($sass_import_path, '.scss')); + + if (!file_put_contents($filepath, "@import \"init\";\n@import \"{$sass_import_path}\";\n")) { + Drush::logger()->log(LogLevel::ERROR, "Failed to generate default SASS file in $filepath."); + return FALSE; + } + return $sass_import_path; } /** @@ -630,8 +1084,117 @@ class BsLibCommands extends DrushCommands { * @return bool * TRUE on success, FALSE on failure. */ - protected function generateFile($file_name, array $options) { - return _bs_base_generate_file($file_name, $options); + protected function generateFile(string $file_name, array $options): bool { + $content = ''; + switch ($file_name) { + case $options['child_machine_name'] . '.info.yml': + /** @var ThemeTools $theme_tools */ + $theme_tools = \Drupal::service('bs_lib.theme_tools'); + + // Lets start from parent info file. + $all_themes = $theme_tools->drupalThemeListInfo(); + $yaml_array = $all_themes[$options['parent_machine_name']]->info; + + // Change basic stuff. + $yaml_array['name'] = $options['child_name']; + $yaml_array['description'] = $options['child_description']; + $yaml_array['base theme'] = $options['parent_machine_name']; + + // Override libraries. + $yaml_array['libraries'] = [$options['child_machine_name'] . '/global-styling']; + + // Remove version, project and datestamp keys that are coming from + // Drupal.org packager. + unset($yaml_array['version']); + unset($yaml_array['project']); + unset($yaml_array['datestamp']); + + // Remove libraries-extend and component-libraries. + unset($yaml_array['libraries-extend']); + unset($yaml_array['component-libraries']); + + $yaml_array['libraries-override'] = $this->generateLibrariesOverride($options['parent_machine_name']); + + // Finally lets add empty line separators for better visual code grouping. + $content .= $theme_tools->regexp(Yaml::encode($yaml_array), [ + "^regions:" => "\nregions:", + "^libraries:" => "\nlibraries:", + "^libraries-override:" => "\nlibraries-override:", + "^bs_versions:" => "\nbs_versions:", + ]); + break; + + case $options['child_machine_name'] . '.libraries.yml': + // Lets start from parent libraries file. + $file_content = file_get_contents($options['parent_path'] . '/' . $options['parent_machine_name'] . '.libraries.yml'); + $yaml_array = Yaml::decode($file_content); + $yaml_array = ['global-styling' => $yaml_array['global-styling']]; + $content .= Yaml::encode($yaml_array); + break; + + case 'sass/variables/_' . $options['child_machine_name'] . '.scss': + $content .= "// Variable overrides and custom variable definitions.\n// Note that first all variables from theme-options.yml file will be loaded and\n// then variables from this file.\n"; + break; + + case 'sass/_init.scss': + $content .= <<<EOD +// Main base file which is responsible of loading all variables in correct +// order and all mixin files we are using. +// +// NOTE that this order is not fixed, feel free to change it you see it fit for +// your custom theme. + +// Theme custom variables and overrides. +@import "variables/{$options['child_machine_name']}"; +@import "variables/icons"; + +// Load variables and other init code from all parent themes. +@import "{$options['parent_machine_name']}/sass/init"; + +EOD; + break; + + case 'gulp-options.yml': + $content .= "# This file holds various gulp configurations that we need in our Gulp process.\n#\n# Note that options from this file will be merged with Gulp options from parent\n# theme.\n\n"; + + // Get parent themes info. + $content .= "parentTheme:\n # Order is important and needs to goes from top most parent to bottom.\n # Default path values will work for standard positions of contrib and custom\n # themes.\n # Theme path to parent theme folder can be relative or absolute."; + + $all_themes = \Drupal::service('bs_lib.theme_tools')->drupalThemeListInfo(); + $parent_themes = \Drupal::service('bs_lib.theme_tools')->drupalGetBaseThemes($all_themes, $options['parent_machine_name']); + foreach (array_keys(array_reverse($parent_themes)) as $parent_theme) { + $relative_path = '../' . UrlGenerator::getRelativePath($options['child_path'], $all_themes[$parent_theme]->subpath) . '/'; + $content .= "\n -\n name: '$parent_theme'\n path: '$relative_path'"; + } + + break; + + case "config/schema/{$options['child_machine_name']}.schema.yml": + $content .= Yaml::encode([ + $options['child_machine_name'] . '.settings' => [ + 'type' => $options['parent_machine_name'] . '.settings', + 'label' => $options['child_name'] . ' settings', + ], + ]); + break; + + case 'README.md': + $content .= "THEME DOCUMENTATION\n-------------------\n\nPut your custom theme documentation here."; + break; + + default: + Drush::logger()->log(LogLevel::ERROR, "Generation of file $file_name not supported."); + return FALSE; + } + + $this->ensureDirectory($file_name, $options['child_path']); + + if (!file_put_contents($options['child_path'] . '/' . $file_name, $content)) { + Drush::logger()->log(LogLevel::ERROR, "Failed to generate file $file_name."); + return FALSE; + } + + return TRUE; } /** @@ -643,8 +1206,43 @@ class BsLibCommands extends DrushCommands { * @return array * Libraries override array. */ - protected function generateLibrariesOverride($parent_machine_name) { - return _bs_base_generate_libraries_override($parent_machine_name); + protected function generateLibrariesOverride(string $parent_machine_name): array { + $parent_theme_info = $this->getThemeInfo($parent_machine_name); + + // Remove all libraries-override elements with false - we don't need them + // duplicated here. + $libraries_override = array_filter($parent_theme_info->info['libraries-override']); + + // Change override keys until core improve this in + // https://www.drupal.org/node/2642122. + $parent_root_path = '/' . $parent_theme_info->subpath . '/'; + foreach ($libraries_override as $key => &$library) { + if (isset($library['css'])) { + foreach ($library['css'] as $library_key => $library_value) { + $css = reset($library_value); + // If this is false and the only thing in the library then we should + // remove it in the same way as we are removing duplicates in + // previous array_filter. + if ($css === FALSE && count($library) === 1) { + unset($libraries_override[$key]); + } + else { + $library['css'][$library_key] = [$parent_root_path . $css => $css]; + } + } + } + } + + // Disable loading of global styles from parent theme. + $libraries_override[$parent_machine_name . '/global-styling'] = FALSE; + + // Override libraries defined in first parent theme. + $parent_library_overrides = $this->getCssLibrariesForOverride($parent_machine_name); + foreach ($parent_library_overrides as $key => $parent_library_override) { + $libraries_override[$key] = $parent_library_override; + } + + return $libraries_override; } /** @@ -656,8 +1254,35 @@ class BsLibCommands extends DrushCommands { * @return array * Arrays of CSS libraries in libraries override format. */ - protected function getCssLibrariesForOverride($theme_machine_name) { - return _bs_base_get_css_libraries_for_override($theme_machine_name); + protected function getCssLibrariesForOverride(string $theme_machine_name): array { + $theme_info = $this->getThemeInfo($theme_machine_name); + $library_path = $theme_info->subpath . '/' . $theme_machine_name . '.libraries.yml'; + + if (!file_exists($library_path)) { + return []; + } + + // Check and override any CSS library that is defined in parent theme. + // @todo - we should allow library override addition only in the case when + // css files does belong into the flatten SASS/CSS file structure. + $file_content = file_get_contents($library_path); + $parent_libraries = Yaml::decode($file_content); + + $parent_library_overrides = []; + foreach ($parent_libraries as $key => $parent_library) { + if ($key == 'global-styling') { + continue; + } + if (isset($parent_library['css'])) { + foreach ($parent_library['css'] as $css_key => $css_library) { + $css_file = key($css_library); + $parent_library['css'][$css_key] = [$css_file => $css_file]; + } + $parent_library_overrides[$theme_machine_name . '/' . $key] = ['css' => $parent_library['css']]; + } + } + + return $parent_library_overrides; } /** @@ -669,42 +1294,19 @@ class BsLibCommands extends DrushCommands { * @return array * Array of all SASS files in the given path. */ - protected function getSassFiles($path) { - return _bs_base_get_sass_files($path); - } + protected function getSassFiles(string $path): array { + $files = $this->fileScanDirectory(DRUPAL_ROOT . '/' . $path . '/sass', '/.*\.scss$/'); - /** - * Regular expression search and replace in the text. - * - * @param string $text - * Text to search and replace. - * @param array $regexps - * Array of regexps searches with it replace values. - * @param string $modifiers - * PHP regular expression modifiers. - * @param string $delimiter - * PHP regular expression delimiter. - * - * @return string - * Replaced text. - */ - protected function regexp($text, array $regexps, $modifiers = 'm', $delimiter = '%') { - return _bs_base_regexp($text,$regexps, $modifiers, $delimiter); - } + // Add some more info's that we need. + foreach ($files as &$file) { + // Add SASS partial flag. + $file->partial = str_starts_with($file->name, '_'); - /** - * Regular expression search and replace in the file. - * - * @param string $file_name - * File path. - * @param array $regexps - * Array of regexps searches with it replace values. - * - * @return bool - * TRUE on success, FALSE other way. - */ - protected function regexpFile($file_name, array $regexps) { - return _bs_base_regexp_file($file_name, $regexps); + // Add theme relative file path. + $file->filepath = substr($file->uri, strpos($file->uri, '/sass/') + 1); + } + + return $files; } /** @@ -733,8 +1335,86 @@ class BsLibCommands extends DrushCommands { * @throws Exception * Throws exception in the case that we try to set non-scalar value. */ - protected function setYmlValue($path, array $values, $add = FALSE) { - _bs_base_set_yml_value($path, $values, $add); + protected function setYmlValue(string $path, array $values, bool $add = FALSE): void { + $write = FALSE; + $file_contents = file_get_contents($path); + + foreach ($values as $yml_key => $value) { + if (!is_scalar($value)) { + throw new Exception("Can not set non scalar value for $yml_key in $path"); + } + + // Regular expression pattern that can locate and change value based on yaml + // array keys. + $pattern = "#" . implode(":\n(.|\n)*?(", explode('.', $yml_key)) . ":\s)(.*?)\n#"; + + $count = 0; + $res = preg_replace_callback($pattern, function ($matches) use ($value) { + return str_replace($matches[2] . $matches[3], $matches[2] . $value, $matches[0]); + }, $file_contents, 1, $count); + + // If variable does not exist and $add flag is turn on we will add this + // value. + // @todo, @note - not sure how this will support two call like in the case + // when bs_versions key does not exist at all: + // + // $values = [ + // 'bs_versions.bs_base' => 8000, + // 'bs_versions.bs_bootstrap' => 8000, + // ]; + // _bs_base_set_yml_value($path, $values, TRUE); + // + // This call will fail in this case. If this happens we will need to + // fix/improve this code. + if ($count === 0 && $add) { + // Convert variable to yaml text format. + $keys = explode('.', $yml_key); + $yaml_value = [array_pop($keys) => $value]; + while ($key = array_pop($keys)) { + $yaml_value = [$key => $yaml_value]; + } + + // Check does part of the yaml value already exist and if yes merge it + // with a new value. + // @note - this will use Yaml decode and encode and we will lose any + // comment that exist. Symfony it self will not implement comments + // support. @see https://github.com/symfony/symfony/issues/22516. + // @todo - however Acquia is using consolidation/comments library to + // overcome this limitation of Symfony Yaml parser. We could implement + // this approach in the future because it would simplify other parts + // of drush script. + // @see https://github.com/acquia/blt/pull/3629/files. + // @see https://github.com/consolidation/comments + $root_key = key($yaml_value); + // Regexp to select all indented lines for a given root key. + // @see https://stackoverflow.com/a/48313919 for a bit more explanation on + // this pattern (multi line mode variation). + $root_key_pattern = "/^$root_key:\R(^ +.+\R)+/m"; + $root_key_count = 0; + $res = preg_replace_callback($root_key_pattern, function ($matches) use ($yaml_value) { + $root_key_value = Yaml::decode($matches[0]); + $root_key_value = array_merge_recursive($root_key_value, $yaml_value); + return Yaml::encode($root_key_value); + }, $file_contents, 1, $root_key_count); + if ($root_key_count === 1) { + $file_contents = $res; + } + // If value does not exist let us simply add it to the end of the file. + else { + $file_contents .= "\n" . Yaml::encode($yaml_value); + } + + $write = TRUE; + } + elseif (!empty($res) && $res !== $file_contents) { + $file_contents = $res; + $write = TRUE; + } + } + + if ($write) { + file_put_contents($path, $file_contents); + } } /** @@ -743,8 +1423,143 @@ class BsLibCommands extends DrushCommands { * @param string $theme_machine_name * Theme machine name. */ - protected function updateSassFiles($theme_machine_name) { - _bs_base_update_sass_files($theme_machine_name); + protected function updateSassFiles(string $theme_machine_name): bool { + /** @var ThemeTools $theme_tools */ + $theme_tools = \Drupal::service('bs_lib.theme_tools'); + + $all_themes = $theme_tools->drupalThemeListInfo(); + $parent_themes = $this->getParentThemes($theme_machine_name); + $target_path = $theme_tools->drupalGetThemePath($theme_machine_name); + $first_parent_machine_name = $all_themes[$theme_machine_name]->info['base theme']; + + $options = [ + 'parent_machine_name' => $first_parent_machine_name, + 'parent_path' => $all_themes[$first_parent_machine_name]->subpath, + 'child_machine_name' => $theme_machine_name, + 'child_path' => $target_path, + 'child_name' => $all_themes[$theme_machine_name]->info['name'], + 'child_description' => $all_themes[$theme_machine_name]->info['description'], + ]; + + // Build SASS info array of parent themes. + $parent_themes_sass_files = []; + /** @var \Drupal\Core\Extension\Extension $theme */ + foreach (array_keys($parent_themes) as $parent_theme_machine_name) { + $parent_themes_sass_files[$parent_theme_machine_name] = $this->getSassFiles($all_themes[$parent_theme_machine_name]->subpath); + } + + // Get target SASS files. + $target_theme_sass_files = $this->getSassFiles($all_themes[$theme_machine_name]->subpath); + + // Add any missing main SASS files from first parent theme to a target theme. + $new_files = FALSE; + $new_sass_files = []; + foreach ($parent_themes_sass_files[$first_parent_machine_name] as $sass_file) { + $target_theme_sass_uri = DRUPAL_ROOT . '/' . $target_path . '/' . $sass_file->filepath; + if (!$sass_file->partial && empty($target_theme_sass_files[$target_theme_sass_uri])) { + $new_sass_files[] = $this->generateSassFile($sass_file, $options); + $new_files = TRUE; + } + } + + // Make sure that _init and variables/_target_theme_machine_name.scss exists. + foreach (['sass/variables/_' . $theme_machine_name . '.scss', 'sass/_init.scss'] as $file) { + if (empty($target_theme_sass_files[DRUPAL_ROOT . '/' . $target_path . '/' . $file])) { + if (!$this->generateFile($file, $options)) { + Drush::logger()->log(LogLevel::ERROR, "Failed to generate default SASS file $file file."); + } + $new_files = TRUE; + } + } + + // If we have new SASS files lets rescan target theme again. + if ($new_files) { + $target_theme_sass_files = $this->getSassFiles($target_path); + } + + // Iterate over all *.scss files and for all non-partial files flatten SASS + // imports. + foreach ($target_theme_sass_files as $sass_file) { + // Do not process partials. + if ($sass_file->partial) { + continue; + } + + $flattened_sass = $this->flattenSassFileImports($sass_file, $theme_machine_name, $all_themes, $parent_themes_sass_files); + + // Check that parent files partials that are added to new files maybe exist + // in existing imports. If yes remove them. This is a case when some + // partials from parent themes are moved to new SASS file due to + // refactoring. + // Before removing duplicated imports check the content of target partials - + // if the partials holds only variables, mixins or functions (no CSS rules) + // then having multiple imports is fine and we should not remove it. + // @TODO - this is a very complex logic which does not need to be valid + // always. If this part of code is making more problems in future then + // consider to remove it and use update functions to handle refactor cases + // on per case base? + foreach ($new_sass_files as $new_file) { + if (empty($new_file)) { + continue; + } + + $new_file_path = $all_themes[$first_parent_machine_name]->parentPath . '/' . $new_file . '.scss'; + $new_sass_file = $parent_themes_sass_files[$first_parent_machine_name][$new_file_path]; + + // Skip newly added SASS file. + if ($new_sass_file->filepath === $sass_file->filepath) { + continue; + } + + $new_sass_file_flattened = $this->flattenSassFileImports($new_sass_file, $theme_machine_name, $all_themes, $parent_themes_sass_files); + // Remove first @import "init"; + array_shift($new_sass_file_flattened); + + $remove_lines = array_intersect($flattened_sass, $new_sass_file_flattened); + if (!empty($remove_lines)) { + foreach ($remove_lines as $line_no => $remove_line) { + if (preg_match("#^(//|/\*)?\s*(@import\s+['\"])([a-zA-Z0-9_@]+)(.*?)['\"]#", $remove_line, $matches)) { + $remove_line_theme = $matches[3]; + $remove_line_filepath_part = $matches[4]; + + if (isset($all_themes[$remove_line_theme]) && !empty($remove_line_filepath_part)) { + $parts = explode('/', $remove_line_filepath_part); + $last_element = array_key_last($parts); + if (!str_starts_with($parts[$last_element], '_')) { + $parts[$last_element] = '_' . $parts[$last_element]; + } + + // Make partial file name. + $partial_filename = DRUPAL_ROOT . '/' . $all_themes[$remove_line_theme]->subpath . join('/', $parts) . '.scss'; + + if (isset($parent_themes_sass_files[$remove_line_theme][$partial_filename])) { + $file_contents = file_get_contents($partial_filename); + if ($file_contents === FALSE) { + Drush::logger()->log(LogLevel::WARNING, "Can not open file $partial_filename for a check."); + return FALSE; + } + + // If there are no CSS rules in this partial we will consider it + // as a variable/mixin/function partial that CAN be included in + // multiple files and therefor we will not remove it. + if (preg_match_all("#^[\.\#\[a-zA-Z0-9\*].+?\s+\{#m", $file_contents, $matches) === 0) { + unset($remove_lines[$line_no]); + } + } + } + } + } + $flattened_sass = array_diff($flattened_sass, $remove_lines); + } + } + + // Save the file. + if (file_put_contents($sass_file->uri, implode('', $flattened_sass)) === FALSE) { + Drush::logger()->log(LogLevel::ERROR, "Failed to write {$sass_file->uri} file."); + } + } + + return TRUE; } } diff --git a/src/ThemeTools.php b/src/ThemeTools.php new file mode 100644 index 0000000000000000000000000000000000000000..cafa8370156f72437b0820fe63b57277603e2cb6 --- /dev/null +++ b/src/ThemeTools.php @@ -0,0 +1,333 @@ +<?php + +namespace Drupal\bs_lib; + +use Drupal\Component\Serialization\Yaml; +use Drupal\Core\DrupalKernel; +use Drupal\Core\Extension\ExtensionDiscovery; +use Drupal\Core\Extension\Discovery\RecursiveExtensionFilterIterator; +use Drupal\Core\Site\Settings; +use Exception; +use stdClass; +use Symfony\Component\HttpFoundation\Request; + +/** + * Usefull functions for theme inspection. + */ +class ThemeTools { + + /** + * Finds all the base themes for the specified theme. + * + * @param array $themes + * An array of available themes. + * @param string $theme + * The name of the theme whose base we are looking for. + * + * @return array + * Returns an array of all the theme's ancestors including specified theme. + */ + public function drupalGetBaseThemes(array $themes, string $theme): array { + $base_themes = [$theme => $themes[$theme]->info['name']]; + + if (!empty($themes[$theme]->info['base theme'])) { + return $this->drupalGetBaseThemes($themes, $themes[$theme]->info['base theme']) + $base_themes; + } + + return $base_themes; + } + + /** + * Returns the first parent theme of passed child theme. + * + * @param string $theme_name + * The name of the child theme whose first parent theme we are looking for. + * + * @return string|NULL + * Returns a theme machine name of first parent theme or NULL if parent does + * not exist. + */ + public function drupalGetParentThemeName($theme_name): ?string { + $themes_info = $this->drupalThemeListInfo(); + $parent_themes = $this->drupalGetBaseThemes($themes_info, $theme_name); + + end($parent_themes); + if (!prev($parent_themes)) { + return NULL; + } + + return key($parent_themes); + } + + /** + * Returns the path to a Drupal theme. + * + * @param string $name + * Theme machine name. + * + * @return string + * The path to the requested theme or an empty string if the item is not + * found. + */ + public function drupalGetThemePath($name): string { + $scan = $this->drupalScan('theme'); + + if (isset($scan[$name])) { + return $scan[$name]->subpath; + } + + return ''; + } + + /** + * Get information's for all themes. + * + * @param bool $reset + * Reset internal cache. + * + * @return array + * Array holding themes information's. + */ + public function drupalThemeListInfo(bool $reset = FALSE): array { + static $themes = []; + + if (!$reset && !empty($themes)) { + return $themes; + } + + $themes = $this->drupalScan('theme', $reset); + foreach ($themes as $theme_name => $theme) { + $themes[$theme_name]->info = Yaml::decode(file_get_contents($theme->pathname)); + } + + return $themes; + } + + /** + * Discovers available extensions of a given type. + * + * For an explanation of how this work see ExtensionDiscovery::scan(). + * + * @param string $type + * The extension type to search for. One of 'profile', 'module', 'theme', or + * 'theme_engine'. + * @param bool $reset + * Reset internal cache. + * + * @return array|null + * An associative array of stdClass objects, keyed by extension name. + */ + public function drupalScan(string $type, bool $reset = FALSE): ?array { + static $processed_files = NULL; + + if (!$reset && !is_null($processed_files)) { + return $processed_files; + } + + $search_dirs = [ + ExtensionDiscovery::ORIGIN_SITES_ALL => 'sites/all', + ExtensionDiscovery::ORIGIN_ROOT => '' + ]; + if (\Drupal::hasService('kernel')) { + $search_dirs[ExtensionDiscovery::ORIGIN_SITE] = \Drupal::getContainer()->getParameter('site.path'); + } + else { + $search_dirs[ExtensionDiscovery::ORIGIN_SITE] = DrupalKernel::findSitePath(Request::createFromGlobals()); + } + + $files = []; + foreach ($search_dirs as $dir) { + $scan_res = $this->drupalScanDirectory($dir); + // Only return extensions of the requested type. + if (isset($scan_res[$type])) { + $files += $scan_res[$type]; + } + } + + // Duplicate files found in later search directories take precedence over + // earlier ones; they replace the extension in the existing $files array. + $processed_files = []; + foreach ($files as $file) { + $processed_files[basename($file->pathname, '.info.yml')] = $file; + } + + return $processed_files; + } + + /** + * Recursively scans a base directory for the extensions it contains. + * + * For an explanation of how this work @see + * ExtensionDiscovery::scanDirectory(). + * + * @param string $dir + * A relative base directory path to scan, without trailing slash. + * + * @return array + * An associative array of stdClass objects, keyed by extension name. + */ + public function drupalScanDirectory(string $dir): array { + $files = []; + + $dir_prefix = ($dir == '' ? '' : "$dir/"); + $absolute_dir = ($dir == '' ? DRUPAL_ROOT : DRUPAL_ROOT . "/$dir"); + + if (!is_dir($absolute_dir)) { + return $files; + } + + $flags = \FilesystemIterator::UNIX_PATHS; + $flags |= \FilesystemIterator::SKIP_DOTS; + $flags |= \FilesystemIterator::FOLLOW_SYMLINKS; + $flags |= \FilesystemIterator::CURRENT_AS_SELF; + $directory_iterator = new \RecursiveDirectoryIterator($absolute_dir, $flags); + + $ignore_directories = Settings::get('file_scan_ignore_directories', []); + + $filter = new RecursiveExtensionFilterIterator($directory_iterator, $ignore_directories); + + $iterator = new \RecursiveIteratorIterator($filter, + \RecursiveIteratorIterator::LEAVES_ONLY, + // Suppress filesystem errors in case a directory cannot be accessed. + \RecursiveIteratorIterator::CATCH_GET_CHILD + ); + + foreach ($iterator as $key => $fileinfo) { + // All extension names in Drupal have to be valid PHP function names due + // to the module hook architecture. + if (!preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/', $fileinfo->getBasename('.info.yml'))) { + continue; + } + + // Determine extension type from info file. + $type = FALSE; + /** @var \SplFileObject $file */ + $file = $fileinfo->openFile('r'); + while (!$type && !$file->eof()) { + preg_match('@^type:\s*(\'|")?(\w+)\1?\s*$@', $file->fgets(), $matches); + if (isset($matches[2])) { + $type = $matches[2]; + } + } + if (empty($type)) { + continue; + } + $name = $fileinfo->getBasename('.info.yml'); + $pathname = $dir_prefix . $fileinfo->getSubPathname(); + + // Determine whether the extension has a main extension file. + // For theme engines, the file extension is .engine. + if ($type == 'theme_engine') { + $filename = $name . '.engine'; + } + // For profiles/modules/themes, it is the extension type. + else { + $filename = $name . '.' . $type; + } + if (!file_exists(DRUPAL_ROOT . '/' . dirname($pathname) . '/' . $filename)) { + $filename = NULL; + } + + $extension = new stdClass(); + $extension->type = $type; + $extension->pathname = $pathname; + $extension->filename = $filename; + // Add dir to subpath, so we can work with multisites also. + $extension->subpath = (!empty($dir) ? $dir . '/' : '') . $fileinfo->getSubPath(); + // Extension parent folder path. + $extension->parentPath = substr($fileinfo->getPath(), 0, -(strlen($name) + 1)); + $extension->origin = $dir; + + $files[$type][$key] = $extension; + } + + return $files; + } + + /** + * Regular expression search and replace in the text. + * + * @param string $text + * Text to search and replace. + * @param array $regexps + * Array of regexps searches with it replace values. + * @param string $modifiers + * PHP regular expression modifiers. + * @param string $delimiter + * PHP regular expression delimiter. + * + * @return string + * Replaced text. + */ + public function regexp(string $text, array $regexps, string $modifiers = 'm', string $delimiter = '%'): string { + $new_content = $text; + foreach ($regexps as $pattern => $value) { + if ($replaced = preg_replace($this->getRegexp($pattern, $modifiers, $delimiter), $value, $new_content)) { + $new_content = $replaced; + } + } + + return $new_content; + } + + /** + * Regular expression search and replace in the file. + * + * @param string $file_name + * File path. + * @param array $regexps + * Array of regexps searches with it replace values. + * + * @return bool + * TRUE on success, FALSE if file can not be open or saved. + */ + public function regexpFile(string $file_name, array $regexps): bool { + $file_contents = file_get_contents($file_name); + if ($file_contents === FALSE) { + return FALSE; + } + + return file_put_contents($file_name, $this->regexp($file_contents, $regexps)); + } + + /** + * Check does regular expression result exist in the file. + * + * @param string $file_name + * File path. + * @param string $pattern + * Regular expression pattern for search. + * + * @return bool + * TRUE if it exists, FALSE other way. + * + * @throws Exception + */ + public function regexpExist(string $file_name, string $pattern): bool { + $file_contents = file_get_contents($file_name); + if ($file_contents === FALSE) { + throw new Exception("Can not open file $file_name."); + } + + $matches = []; + return preg_match($this->getRegexp($pattern), $file_contents, $matches) === 1; + } + + /** + * Wraps a regexp pattern. + * + * @param string $pattern + * Regexp pattern. + * @param string $modifiers + * PHP regular expression modifiers. + * @param string $delimiter + * PHP regular expression delimiter. + * + * @return string + * Wrapped regexp pattern. + */ + protected function getRegexp(string $pattern, string $modifiers = 'm', string $delimiter = '%'): string { + return $delimiter . $pattern . $delimiter . $modifiers; + } + +}