bs_lib-8.x-1.0-alpha3/src/Commands/BsLibCommands.php

src/Commands/BsLibCommands.php
<?php

namespace Drupal\bs_lib\Commands;

use Drupal\bs_lib\ThemeTools;
use Drupal\Component\Serialization\Yaml;
use Drush\Commands\DrushCommands;
use Drush\Drush;
use Exception;
use stdClass;
use Symfony\Component\Routing\Generator\UrlGenerator;

/**
 * Drush commands for bs_lib module.
 */
class BsLibCommands extends DrushCommands {

  /**
   * Create a new bs_base compatible child theme.
   *
   * @command bs:theme-create
   *
   * @param string $parent_machine_name Parent theme machine name.
   * @param string $child_machine_name Child theme machine name.
   * @param string $child_name Child theme name.
   * @param string $child_description Child theme description.
   *
   * @bootstrap max
   * @aliases bs-tc,bs-theme-create
   *
   * @usage drush bs-tc bs_bootstrap custom_theme 'Custom theme' 'Custom theme description'
   *   Create a new bs_base compatible child theme.
   *
   * @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.
    if (preg_match('@[^a-z0-9_]+@', $child_machine_name)) {
      throw new \Exception('The machine-readable name "' . $child_machine_name . '" must contain only lowercase letters, numbers, and hyphens.');
    }

    $this->output()->writeln("Starting $child_machine_name theme creation");

    // Parent theme should exist.
    $parent_path = ThemeTools::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 = ThemeTools::drupalGetThemePath($child_machine_name))) {
      throw new \Exception("Child theme already exist on $child_path file system.");
    }

    // Create child theme directory.
    // Figure child theme path respecting multisite installation.
    $boot = Drush::bootstrap();
    $site_path = $boot->confPath();
    if ($site_path === 'sites/default') {
      // For default site we are using standard web/themes/custom folder.
      $child_path = 'themes/custom/' . $child_machine_name;
    }
    else {
      // For multi sites we will use multisite folder.
      $child_path = $site_path . '/themes/custom/' . $child_machine_name;
    }
    Drush::logger()->info("Creating {$child_path} folder.");
    if (!mkdir($child_path, 0755, TRUE)) {
      throw new \Exception("Failed to create child theme directory on $child_path path.");
    }

    $options = [
      'parent_machine_name' => $parent_machine_name,
      'parent_path' => $parent_path,
      'child_machine_name' => $child_machine_name,
      'child_path' => $child_path,
      'child_name' => $child_name,
      'child_description' => $child_description,
    ];

    // Copy files from parent and change/apply text changes to labels.
    $this->copyThemeFiles($options);

    // Replace text in copied files.
    $this->reconfigureThemeFiles($options);

    // Generate some files from the scratch.
    $this->generateFile("config/schema/{$child_machine_name}.schema.yml", $options);
    $this->generateFile('gulp-options.yml', $options);
    $this->generateFile('gulp-tasks.js', $options);
    $this->generateFile($child_machine_name . '.info.yml', $options);
    $this->generateFile($child_machine_name . '.libraries.yml', $options);
    $this->generateFile('README.md', $options);

    // Rebuild themes static cache because new theme is created.
    ThemeTools::drupalThemeListInfo(TRUE);

    // Make sure we are on latest parent theme versions.
    $update_functions = $this->GetUpdateHooks($child_machine_name);
    if (!empty($update_functions)) {
      $bs_versions = [];
      foreach ($update_functions as $theme_name => $theme_updates) {
        // Get last update.
        end($theme_updates['functions']);
        $last_function = key($theme_updates['functions']);
        $bs_versions['bs_versions.' . $theme_name] = (int) $last_function;
      }

      $all_themes = ThemeTools::drupalThemeListInfo();
      ThemeTools::setYmlFileValue($all_themes[$child_machine_name]->pathname, $bs_versions, TRUE);
    }

    // Update and flatten SASS files.
    $this->updateSassFiles($child_machine_name);

    // Rebuild theme assets.
    $this->themeBuild($child_machine_name);
  }

  /**
   * Update existing bs_lib compatible child theme.
   *
   * @command bs:theme-update
   *
   * @param string $target_machine_name Theme machine name.
   * @bootstrap max
   * @aliases bs-tu,bs-theme-update
   *
   * @usage drush bs-tu custom_theme
   *   Create a new bs_base compatible child theme.
   *
   * @throws Exception
   */
  public function themeUpdate($target_machine_name) {
    $this->output()->writeln("Updating a $target_machine_name theme");

    $target_path = ThemeTools::drupalGetThemePath($target_machine_name);
    if (empty($target_path)) {
      throw new \Exception('Target theme does not exist.');
    }

    // Do not store results of getParentThemes() to allow update hooks to clear
    // internal cache of ThemeTools::drupalThemeListInfo() if needed.
    if (empty($this->getParentThemes($target_machine_name))) {
      throw new \Exception('Parent themes are missing.');
    }

    // Run update hooks.
    $this->themeRunUpdateHooks($target_machine_name);

    $all_themes = ThemeTools::drupalThemeListInfo();
    $first_parent_machine_name = ThemeTools::drupalGetParentThemeName($target_machine_name);

    $this->updateSassFiles($target_machine_name);

    // Check for any new or removed CSS library in parent theme and update
    // libraries-override section.
    $parent_theme_libraries_override = $this->generateLibrariesOverride($first_parent_machine_name);
    $target_info_array = $all_themes[$target_machine_name]->info;
    $target_theme_libraries_override = $target_info_array['libraries-override'];
    // Keep only the libraries from target that are not from parents.
    foreach ($target_theme_libraries_override as $library_key => $library_value) {
      // We have it in parent already.
      if (isset($parent_theme_libraries_override[$library_key])) {
        unset($target_theme_libraries_override[$library_key]);
      }
      // We do not have it in parent, but it does belong to parent themes. We
      // assume that library was removed from parent theme or that it is in
      // parent parents. In this case we will remove it from here.
      elseif ($this->libraryKeyComingFromParents($library_key, $target_machine_name)) {
        unset($target_theme_libraries_override[$library_key]);
      }
    }
    // We start from parent generated libraries override.
    $new_libraries_override = array_merge($parent_theme_libraries_override, $target_theme_libraries_override);
    // Update info file with new libraries override.
    $info_content = file_get_contents($all_themes[$target_machine_name]->pathname);
    $info_content = preg_replace('/^libraries-override:\R(^ +.+\R)+/m', Yaml::encode(['libraries-override' => $new_libraries_override]), $info_content);
    file_put_contents($all_themes[$target_machine_name]->pathname, $info_content);

    // Rebuild assets.
    $this->themeBuild($target_machine_name);
  }

  /**
   * Run build script in provided theme.
   *
   * @command bs:theme-build
   *
   * @param string $theme_machine_name Theme machine name.
   *
   * @bootstrap max
   * @aliases bs-tb,bs-theme-build
   *
   * @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 = ThemeTools::drupalGetThemePath($theme_machine_name);
    if (empty($target_path)) {
      throw new \Exception("Target theme {$theme_machine_name} does not exist.");
    }

    $this->drushBuild($target_path);
  }

  /**
   * Returns array with unique lines.
   *
   * This will eliminate all duplicate lines in array, but it will also compare
   * commented lines with not commented and eliminate that duplicates also. For
   * example:
   *
   * array(
   *   '@import "bs_bootstrap/sass/components/partials/alert";',
   *   '//@import "bs_bootstrap/sass/components/partials/alert";',
   * )
   *
   * This two values are considered duplicates also and the result will be
   *
   * array(
   *   '//@import "bs_bootstrap/sass/components/partials/alert";',
   * )
   *
   * @param array $lines
   *   Array of lines.
   *
   * @return array
   *   Array of unique 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;
  }

  /**
   * Copy general theme files from parent theme to child theme.
   *
   * @param array $options
   *   Array of options having next keys:
   *   - parent_machine_name
   *   - parent_path
   *   - child_path
   *   - child_name
   *   - child_description.
   */
  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',
      'favicon.svg',
      '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 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 folder will be created automatically if they do
   * not exist.
   *
   * @param string $folder
   *   File name.
   * @param array $options
   *   Array of options having next keys:
   *   - parent_machine_name
   *   - parent_path
   *   - child_machine_name
   *   - child_path.
   *
   * @return bool
   *   TRUE if the folder was copied, FALSE otherwise.
   */
  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()->warning("Source folder $folder in {$options['parent_path']} does not exist, can not copy.");
      return FALSE;
    }

    // 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;
    }

    if (!$this->copyFolder($options['parent_path'] . '/' . $folder, $options['child_path'] . '/' . $folder)) {
      Drush::logger()->error("Failed to copy $folder folder from {$options['parent_path']} to {$options['child_path']}.");
      return FALSE;
    }

    return TRUE;
  }

  /**
   * Copy file 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 not
   * exist.
   *
   * @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 bool
   *   TRUE if the file was copied, FALSE otherwise.
   */
  protected function copyFile(string $file, array $options): bool {
    try {
      ThemeTools::copyFile($file, $options);
    }
    catch (\RuntimeException $e) {
      Drush::logger()->error($e->getMessage());
      return FALSE;
    }
    return TRUE;
  }

  /**
   * Recursive copy of source folder to the destination.
   *
   * 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 $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 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;
    }

    // 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;
  }

  /**
   * Finds all parent themes for the specified theme.
   *
   * @param string $theme_machine_name
   *   The machine name of the theme whose parent themes we are looking for.
   *
   * @return array
   *   Returns an array of all the parent themes.
   */
  protected function getParentThemes(string $theme_machine_name): array {
    $all_themes = ThemeTools::drupalThemeListInfo();
    $parent_themes = ThemeTools::drupalGetBaseThemes($all_themes, $theme_machine_name);
    array_pop($parent_themes);
    return $parent_themes;
  }

  /**
   * Get theme information.
   *
   * @param string $theme_machine_name
   *   Theme machine name.
   *
   * @return mixed|null
   *   Theme info object or NULL if theme does not exist.
   */
  protected function getThemeInfo(string $theme_machine_name): mixed {
    $all_themes = ThemeTools::drupalThemeListInfo();
    return $all_themes[$theme_machine_name] ?? NULL;
  }

  /**
   * Get all child themes for passed parent_theme.
   *
   * @todo Remove? Doesn't seem to be used.
   *
   * @param string $parent_theme
   *   Machine name of parent theme.
   *
   * @return array
   *   Array of all child themes machine names. Empty array if child themes does
   *   not exist.
   */
  protected function findChildThemes(string $parent_theme): array {
    $child_themes = [];

    $themes = ThemeTools::drupalThemeListInfo();
    foreach ($themes as $theme => $theme_info) {
      if ($theme === $parent_theme) {
        continue;
      }
      $parent_themes = ThemeTools::drupalGetBaseThemes($themes, $theme);
      if (isset($parent_themes[$parent_theme])) {
        $child_themes[$theme] = $theme;
      }
    }

    return $child_themes;
  }

  /**
   * Check that library is coming from theme parent themes or bs_lib module.
   *
   * @param string $library_key
   *   Library key.
   * @param string $theme_machine_name
   *   Theme machine name.
   *
   * @return bool
   *   TRUE if library is coming from parents, FALSE other way.
   */
  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;
  }

  /**
   * Replace text in theme files so all configurations are correct.
   *
   * @param array $options
   *   Array of options having next keys:
   *   - parent_machine_name
   *   - parent_path
   *   - child_path
   *   - child_name
   *   - child_description.
   *
   * @throws Exception
   */
  protected function reconfigureThemeFiles(array $options): void {
    // Yaml value replaces.
    $yaml_settings_path = 'config/install/' . $options['child_machine_name'] . '.settings.yml';
    $yaml_files = [
      $yaml_settings_path => [
        'logo.path' => $options['child_path'] . '/logo.svg',
      ],
    ];

    $parent_favicon_path = ThemeTools::getYmlFileValue($options['parent_path'] . '/config/install/' . $options['parent_machine_name'] . '.settings.yml', 'favicon.path');
    if ($parent_favicon_path) {
      $favicon_ext = strtolower(pathinfo($parent_favicon_path, PATHINFO_EXTENSION));
      $yaml_files[$yaml_settings_path]['favicon.path'] = $options['child_path'] . '/favicon.' . $favicon_ext;
    }

    foreach ($yaml_files as $file => $value) {
      ThemeTools::setYmlFileValue($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 (!ThemeTools::regexpFile($file_name, $regexps)) {
        Drush::logger()->warning("Can not process file $file_name for regexp search&replace.");
      }
    }
  }

  /**
   * Get theme update functions.
   *
   * @param string $target_machine_name
   *   Target theme machine name.
   *
   * @return array
   *   Array of update functions.
   */
  protected function getUpdateHooks(string $target_machine_name): array {
    $update_functions = [];
    $all_themes = ThemeTools::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+)/', $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;
  }

  /**
   * Run theme update functions.
   *
   * @param string $target_machine_name
   *   Target theme machine name.
   *
   * @throws Exception
   */
  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.');
      return;
    }

    // Print a list of pending updates for this module and get confirmation.
    $this->output()->writeln('The following updates are pending:');
    $this->output()->writeln(drush_html_to_text('<h2>'));

    foreach ($update_functions as $theme_name => $theme_updates) {
      $this->output()->writeln($theme_name . ' theme : ');
      foreach ($theme_updates['functions'] as $version => $description) {
        $this->output()->writeln(' ' . $version . ' -   ' . strip_tags($description));
      }
    }
    $this->output()->writeln(drush_html_to_text('<h2>'));

    if (!$this->confirm('Do you wish to run all pending updates?', TRUE)) {
      return;
    }

    $this->output()->writeln(drush_html_to_text('<h2>'));
    $this->output()->writeln('Running next updates:');

    // Load install files and execute update functions.
    $bs_versions = [];
    foreach ($update_functions as $theme_name => $theme_updates) {
      include_once $theme_updates['file'];
      foreach ($theme_updates['functions'] as $version => $description) {
        $update_function = $theme_name . '_bs_update_' . $version;
        $this->output()->writeln('  ' . $version . ' -   ' . strip_tags($description));
        $update_function($target_machine_name);
      }

      // Update theme info bs_versions.
      $bs_versions['bs_versions.' . $theme_name] = (int) $version;
    }

    // Update info file with the latest versions.
    $all_themes = ThemeTools::drupalThemeListInfo();
    ThemeTools::setYmlFileValue($all_themes[$target_machine_name]->pathname, $bs_versions, TRUE);
  }

  /**
   * Run build script in provided theme path.
   *
   * @param string $path
   *   Path to theme folder.
   */
  protected function drushBuild(string $path): void {
    // Install npm packages and execute gulp sass compilation.
    Drush::logger()->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');
  }

  /**
   * Ensure that theme filename has all directories.
   *
   * @param string $filename
   *   Filename with optional subdirectories in path that we ensure they exist.
   * @param string $path
   *   Base path in which we will check $filename subdirectories.
   *
   * @return bool
   *   TRUE on success, FALSE on error.
   */
  protected function ensureDirectory(string $filename, string $path): bool {
    try {
      ThemeTools::ensureDirectory($filename, $path);
    }
    catch (\RuntimeException $e) {
      Drush::logger()->error($e->getMessage());
      return FALSE;
    }
    return TRUE;
  }

  /**
   * Finds all files that match a given mask in a given directory.
   *
   * For additional information see file_scan_directory().
   *
   * @param string $dir
   *   The base directory or URI to scan, without trailing slash.
   * @param string $mask
   *   The preg_match() regular expression for files to be included.
   * @param array $options
   *   An associative array of additional options, with the following elements:
   *   - 'nomask': The preg_match() regular expression for files to be excluded.
   *     Defaults to the 'file_scan_ignore_directories' setting.
   *   - 'callback': The callback function to call for each match. There is no
   *     default callback.
   *   - 'recurse': When TRUE, the directory scan will recurse the entire tree
   *     starting at the provided directory. Defaults to TRUE.
   *   - 'key': The key to be used for the returned associative array of files.
   *     Possible values are 'uri', for the file's URI; 'filename', for the
   *     basename of the file; and 'name' for the name of the file without the
   *     extension. Defaults to 'uri'.
   *   - 'min_depth': Minimum depth of directories to return files from. Defaults
   *     to 0.
   *
   * @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(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;
  }

  /**
   * Flatten all parent theme @import directives in a SASS file.
   *
   * @param object $target_sass_file
   *   Target SASS file object.
   * @param string $target_machine_name
   *   Target theme machine name.
   * @param array $current_themes
   *   Array of current themes.
   * @param array $parent_themes_sass_files
   *   Array of all SASS files from parent theme.
   * @param int $depth
   *   Current recursion depth, internally used.
   *
   * @return array
   *   Returns array of flattened SASS @import directives.
   */
  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()->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()->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()->error("Parsing of SASS file $target_sass_file->uri failed for some reason for line  $line\n");
        }
      }
    }

    return $flatten_lines;
  }

  /**
   * Generate child SASS file from parent theme.
   *
   * @param object $parent_sass_file
   *   Plain PHP object holding SASS file information.
   * @param array $options
   *   Array of parent/child options.
   *
   * @return string|bool
   *   String of SASS import path on success, FALSE other way.
   */
  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()->error("Failed to generate default SASS file in $filepath.");
      return FALSE;
    }
    return $sass_import_path;
  }

  /**
   * Generate theme file with a default content.
   *
   * @param string $file_name
   *   File name.
   * @param array $options
   *   Options array.
   *
   * @return bool
   *   TRUE on success, FALSE on failure.
   */
  protected function generateFile(string $file_name, array $options): bool {
    $content = '';
    switch ($file_name) {
      case $options['child_machine_name'] . '.info.yml':
        // Let's start from parent info file.
        $all_themes = ThemeTools::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 .= ThemeTools::regexp(Yaml::encode($yaml_array), [
          "^dependencies:" => "\ndependencies:",
          "^regions:" => "\nregions:",
          "^libraries:" => "\nlibraries:",
          "^libraries-override:" => "\nlibraries-override:",
          "^bs_versions:" => "\nbs_versions:",
        ]);
        break;

      case $options['child_machine_name'] . '.libraries.yml':
        // Let's 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':
        // Copy content form parent theme template.variables.scss if it exists.
        if (file_exists($options['parent_path'] . '/template.variables.scss')) {
          $content .= file_get_contents($options['parent_path'] . '/template.variables.scss');
        }
        else {
          $content .= "// Variable overrides and custom variable definitions.\n";
        }

        break;

      case 'sass/_init.scss':
        $functions_import = ThemeTools::getThemeSASSImportFunctions($options['child_machine_name']);

        $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.

{$functions_import}

// 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 = ThemeTools::drupalThemeListInfo();
        $parent_themes = ThemeTools::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 'gulp-tasks.js':
        $content .= "// Define gulp tasks.\nmodule.exports = function(gulp, sass, plugins, options) {\n\n  'use strict';\n\n  // Put your custom tasks here.\n};\n";

        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()->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()->error("Failed to generate file $file_name.");
      return FALSE;
    }

    return TRUE;
  }

  /**
   * Get all CSS libraries from passed theme that child theme needs to override.
   *
   * @param string $parent_machine_name
   *   Parent theme machine name.
   *
   * @return array
   *   Libraries override array.
   */
  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;
  }

  /**
   * Get CSS libraries from theme in libraries override format.
   *
   * @param string $theme_machine_name
   *   Theme machine name from which we are getting CSS libraries.
   *
   * @return array
   *   Arrays of CSS libraries in libraries override format.
   */
  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;
  }

  /**
   * Get array of all SASS files in the given path.
   *
   * @param string $path
   *   Theme path.
   *
   * @return array
   *   Array of all SASS files in the given path.
   */
  protected function getSassFiles(string $path): array {
    $files = $this->fileScanDirectory(DRUPAL_ROOT . '/' . $path . '/sass', '/.*\.scss$/');

    // Add some more info's that we need.
    foreach ($files as &$file) {
      // Add SASS partial flag.
      $file->partial = str_starts_with($file->name, '_');

      // Add theme relative file path.
      $file->filepath = substr($file->uri, strpos($file->uri, '/sass/') + 1);
    }

    return $files;
  }

  /**
   * Update SASS files in theme and do SASS import flattening.
   *
   * @param string $theme_machine_name
   *   Theme machine name.
   */
  protected function updateSassFiles(string $theme_machine_name): bool {
    $all_themes = ThemeTools::drupalThemeListInfo();
    $parent_themes = $this->getParentThemes($theme_machine_name);
    $target_path = ThemeTools::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()->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()->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()->error("Failed to write {$sass_file->uri} file.");
      }
    }

    return TRUE;
  }

}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc