bs_lib-8.x-1.0-alpha3/src/ThemeTools.php

src/ThemeTools.php
<?php

namespace Drupal\bs_lib;

use Drupal\Component\Serialization\Yaml;
use Drupal\Core\DrupalKernel;
use Drupal\Core\Extension\Discovery\RecursiveExtensionFilterCallback;
use Drupal\Core\Extension\ExtensionDiscovery;
use Drupal\Core\Site\Settings;
use Exception;
use stdClass;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Finder\Finder;

/**
 * Usefull functions for theme inspection.
 */
class ThemeTools {

  /**
   * 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.
   */
  public static 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)) {
      throw new \RuntimeException("Source file $file in {$options['parent_path']} does not exist, can not copy.");
    }

    // 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.
    static::ensureDirectory($new_file_name, $options['child_path']);

    // Copy file from parent theme folder to child theme folder.
    if (!copy($options['parent_path'] . '/' . $file, $options['child_path'] . '/' . $new_file_name)) {
      throw new \RuntimeException("Failed to copy $file file from {$options['parent_path']} to {$options['child_path']}.");
    }

    return TRUE;
  }

  /**
   * 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.
   */
  public static function ensureDirectory(string $filename, string $path): void {
    // 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)) {
        throw new \RuntimeException("Failed to create copy target directory $dirname.");
      }
    }
  }

  /**
   * 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 static function drupalGetBaseThemes(array $themes, string $theme): array {
    $base_themes = [$theme => $themes[$theme]->info['name']];

    if (!empty($themes[$theme]->info['base theme'])) {
      return ThemeTools::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 static function drupalGetParentThemeName($theme_name): ?string {
    $themes_info = ThemeTools::drupalThemeListInfo();
    $parent_themes = ThemeTools::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 static function drupalGetThemePath($name): string {
    $scan = ThemeTools::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 static function drupalThemeListInfo(bool $reset = FALSE): array {
    static $themes = [];

    if (!$reset && !empty($themes)) {
      return $themes;
    }

    $themes = ThemeTools::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 static 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 = ThemeTools::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 static 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', []);

    $callback = new RecursiveExtensionFilterCallback($ignore_directories);
    $filter = new \RecursiveCallbackFilterIterator($directory_iterator, [$callback, 'accept']);

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

  /**
   * Get all SASS import functions for a given theme and all of it parent
   * themes.
   *
   * @param string $theme_name
   *   The machine name of the theme.
 * @return string
   *   String of SASS import functions declarations.
   */
  public static function getThemeSASSImportFunctions(string $theme_name): string {
    $themes_info = ThemeTools::drupalThemeListInfo();
    $parent_themes = ThemeTools::drupalGetBaseThemes($themes_info, $theme_name);

    // We always import functions from Bootstrap.
    $import_functions[] = '// Import functions from all themes first.';
    $import_functions[] = '@import "bootstrap/scss/functions";';

    // Check if current theme and all of it parent themes have a
    // sass/_functions.scss and if yes add a correct import declaration.
    foreach ($parent_themes as $theme => $theme_label) {
      $functions_path = $themes_info[$theme]->subpath . '/sass/_functions.scss';
      if (file_exists($functions_path)) {
        if ($theme === $theme_name) {
          $import_functions[] = '@import "functions";';
        }
        else {
          $import_functions[] = '@import "' . $theme . '/sass/functions";';
        }
      }
    }

    return join("\n", $import_functions);
  }

  /**
   * 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 static function regexp(string $text, array $regexps, string $modifiers = 'm', string $delimiter = '%', $limit = -1): string {
    $new_content = $text;
    foreach ($regexps as $pattern => $value) {
      if ($replaced = preg_replace(ThemeTools::getRegexp($pattern, $modifiers, $delimiter), $value, $new_content, $limit)) {
        $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 static function regexpFile(string $file_name, array $regexps, $limit = -1): bool {
    $file_contents = file_get_contents($file_name);
    if ($file_contents === FALSE) {
      return FALSE;
    }

    return file_put_contents($file_name, ThemeTools::regexp($file_contents, $regexps, limit: $limit));
  }

  /**
   * 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 static 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(ThemeTools::getRegexp($pattern), $file_contents, $matches) === 1;
  }

  /**
   * Apply regex replacements to multiple files matching a pattern.
   *
   * @param string $dir
   *   Directory that holds files.
   * @param string|array $patterns
   *   File patters for searching like '*.scss'.
   * @param array $replacements
   *   Array of regex pattern => replacement pairs.
   */
  public static function regexpFiles(string $dir, string|array $patterns, array $replacements): void
  {
    $finder = new Finder();
    $finder->files()->in($dir)->name($patterns);
    foreach ($finder as $file) {
      self::regexpFile((string) $file, $replacements);
    }
  }

  /**
   * Check if pattern exists in multiple files matching a glob pattern.
   *
   * @param string $dir
   *   Directory that holds files.
   * @param string|array $file_patterns
   *   File patters for searching like '*.scss'.
   * @param string $pattern
   *   Regexp pattern to search for.
   *
   * @return array
   *   Array of files containing the pattern.
   *
   * @throws Exception
   */
  public static function regexpExistFiles(string $dir, string|array $file_patterns, string $pattern): array
  {
    $matching_files = [];

    $finder = new Finder();
    $finder->files()->in($dir)->name($file_patterns);
    foreach ($finder as $file) {
      if (self::regexpExist((string)$file, $pattern)) {
        $matching_files[] = $file;
      }
    }

    return $matching_files;
  }

  /**
   * 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.
   */
  public static function getRegexp(string $pattern, string $modifiers = 'm', string $delimiter = '%'): string {
    return $delimiter . $pattern . $delimiter . $modifiers;
  }

  /**
   * Retrieves a nested value from a YAML file using a dot-separated key.
   *
   * This method reads and decodes a YAML file, then traverses the resulting
   * array using the provided dot-separated key (e.g., 'favicon.path').
   * If the key does not exist or the file is invalid, it returns the default value.
   *
   * @param string $path
   *   The full file path to the YAML file.
   * @param string $key
   *   A dot-separated key indicating the value to retrieve.
   * @param mixed|null $default
   *   The default value to return if the file is unreadable, invalid,
   *   or the key path does not exist.
   *
   * @return mixed
   *   The value found at the specified key path, or the default value.
   */
  public static function getYmlFileValue(string $path, string $key, mixed $default = NULL) {
    $file_content = file_get_contents($path);
    if (empty($file_content)) {
      return $default;
    }

    $yaml_array = Yaml::decode($file_content);
    if (!is_array($yaml_array)) {
      return $default;
    }

    foreach (explode('.', $key) as $segment) {
      if (array_key_exists($segment, $yaml_array)) {
        $yaml_array = $yaml_array[$segment];
      }
      else {
        return $default;
      }
    }
    return $yaml_array;
  }

  /**
   * Set primitive values in a yml file.
   *
   * Please note that this implementation is not perfect but exist only to
   * support the needs of this drush implementation. There are a couple of
   * limitations that are explained in function comments. Most importantly
   * values in values array can be only primitive types for now.
   *
   * @param string $path
   *   Yaml file path.
   * @param array $values
   *   Array with yaml values to set. The element key is a combination of yaml
   *     keys and element value is yaml value. For example:
   *
   *     $values['logo.path'] = 'custom/logo/path'
   *
   *   will do
   *
   *     logo:
   *       path: 'custom/logo/path'.
   * @param bool $add
   *   If TRUE then value does not exist, and it will be added.
   *
   * @throws Exception
   *   Throws exception in the case that we try to set non-scalar value.
   */
  public static function setYmlFileValue(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.
      //
      // It will split keys like `key1.key2.key3` into regular expression that
      // can find patterns like
      //
      // key1:
      //   stuff...
      //   key2:
      //     stuff...
      //     key3: value3
      //
      // and isolate `value3` for replacement with a new value.
      $pattern = '/^' . str_replace('.', "\:\n.*?", $yml_key) . ':\s*([^\n]*)/sm';

      $count = 0;
      $res = preg_replace_callback($pattern, function ($matches) use ($value) {
        return str_replace($matches[1], $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);
    }
  }

  /**
   * Execute update of caniuse browserlist-db for a given theme.
   *
   * @param string $theme_name
   *   Theme machine name.
   *
   * @return void
   */
  public static function updateBrowserList(string $theme_name): void {
    $target_path = ThemeTools::drupalGetThemePath($theme_name);
    if (empty($target_path)) {
      throw new \RuntimeException("Target theme {$theme_name} does not exist.");
    }

    exec('cd ' . $target_path . ' && npm run update-browserslist');
  }

}

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

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