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