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');
}
}
