Skip to content
Snippets Groups Projects
Verified Commit d82e52c0 authored by Alex Pott's avatar Alex Pott
Browse files

Issue #3364885 by andy-blum, mglaman, alexpott, mherchel, lauriii, smustgrave,...

Issue #3364885 by andy-blum, mglaman, alexpott, mherchel, lauriii, smustgrave, isholgueras, Dave Reid, kostyashupenko, matthieuscarset, camoa, ctrlADel, fjgarlin, jwilson3: Allow themes to use a starterkit.yml file so it is easier to use the theme generator

(cherry picked from commit 6c2ed729)
parent d460133a
No related branches found
No related tags found
33 merge requests!12802Issue #3537193 by opauwlo: Add enable absolute path option for CKEditor5 image uploads,!12745Fixed: Path alias language doesn't changes on changing of node language,!12684Issue #3220784,!12537Add ViewsConfigUpdater deprecation support for default_argument_skip_url,!12523Issue #3493858 by vidorado, xavier.masson, smustgrave: Extend ViewsBlockBase...,!122353526426-warning-for-missing,!12212Issue #3445525 by alexpott, japerry, catch, mglaman, longwave: Add BC layer...,!11958Issue #3490507 by alexpott, smustgrave: Fix bogus mocking in...,!11769Issue #3517987: Add option to contextual filters to encode slashes in query parameter.,!11185Issue #3477324 by andypost, alexpott: Fix usage of str_getcsv() and fgetcsv() for PHP 8.4,!10602Issue #3438769 by vinmayiswamy, antonnavi, michelle, amateescu: Sub workspace does not clear,!10301Issue #3469309 by mstrelan, smustgrave, moshe weitzman: Use one-time login...,!10187Issue #3487488 by dakwamine: ExtensionMimeTypeGuesser::guessMimeType must support file names with "0" (zero) like foo.0.zip,!9944Issue #3483353: Consider making the createCopy config action optionally fail...,!9929Issue #3445469 by pooja_sharma, smustgrave: Add additional test coverage for...,!9787Resolve issue 3479427 - bootstrap barrio issue under Windows,!9742Issue #3463908 by catch, quietone: Split OptionsFieldUiTest into two,!9526Issue #3458177 by mondrake, catch, quietone, godotislate, longwave, larowlan,...,!8738Issue #3424162 by camilledavis, dineshkumarbollu, smustgrave: Claro...,!8704Make greek characters available in ckeditor5,!8597Draft: Issue #3442259 by catch, quietone, dww: Reduce time of Migrate Upgrade tests...,!8533Issue #3446962 by kim.pepper: Remove incorrectly added...,!8517Issue #3443748 by NexusNovaz, smustgrave: Testcase creates false positive,!8325Update file Sort.php,!8095Expose document root on install,!7930Resolve #3427374 "Taxonomytid viewsargumentdefault plugin",!7627Issue #3439440 by nicxvan, Binoli Lalani, longwave: Remove country support from DateFormatter,!7445Issue #3440169: When using drupalGet(), provide an associative array for $headers,!7401#3271894 Fix documented StreamWrapperInterface return types for realpath() and dirname(),!7384Add constraints to system.advisories,!6502Draft: Resolve #2938524 "Plach testing issue",!38582585169-10.1.x,!3226Issue #2987537: Custom menu link entity type should not declare "bundle" entity key
Pipeline #131294 passed with warnings
Pipeline: drupal

#131323

    Pipeline: drupal

    #131313

      Pipeline: drupal

      #131301

        <?php
        declare(strict_types=1);
        namespace Drupal\Core\Command;
        use Composer\Autoload\ClassLoader;
        ......@@ -8,17 +10,19 @@
        use Drupal\Core\Extension\Extension;
        use Drupal\Core\Extension\ExtensionDiscovery;
        use Drupal\Core\Extension\InfoParser;
        use Drupal\Core\File\FileSystem;
        use Drupal\Core\Theme\StarterKitInterface;
        use Symfony\Component\Console\Command\Command;
        use Symfony\Component\Filesystem\Filesystem;
        use Symfony\Component\Finder\Finder;
        use Symfony\Component\Console\Input\InputArgument;
        use Symfony\Component\Console\Input\InputInterface;
        use Symfony\Component\Console\Input\InputOption;
        use Symfony\Component\Console\Output\OutputInterface;
        use Symfony\Component\Console\Question\ConfirmationQuestion;
        use Symfony\Component\Console\Style\SymfonyStyle;
        use Symfony\Component\Finder\Glob;
        use Symfony\Component\Process\Process;
        use Twig\Util\TemplateDirIterator;
        use function Symfony\Component\String\u;
        /**
        * Generates a new theme based on latest default markup.
        ......@@ -35,356 +39,321 @@ class GenerateTheme extends Command {
        /**
        * {@inheritdoc}
        */
        public function __construct(string $name = NULL) {
        public function __construct(string $name = NULL, ?string $root = NULL) {
        parent::__construct($name);
        $this->root = dirname(__DIR__, 5);
        $this->root = $root ?? dirname(__DIR__, 5);
        }
        /**
        * {@inheritdoc}
        */
        protected function configure() {
        protected function configure(): void {
        $this->setName('generate-theme')
        ->setDescription('Generates a new theme based on latest default markup.')
        ->addArgument('machine-name', InputArgument::REQUIRED, 'The machine name of the generated theme')
        ->addOption('name', NULL, InputOption::VALUE_OPTIONAL, 'A name for the theme.')
        ->addOption('description', NULL, InputOption::VALUE_OPTIONAL, 'A description of your theme.')
        ->addOption('path', NULL, InputOption::VALUE_OPTIONAL, 'The path where your theme will be created. Defaults to: themes')
        ->addOption('description', NULL, InputOption::VALUE_OPTIONAL, 'A description of your theme.', '')
        ->addOption('path', NULL, InputOption::VALUE_OPTIONAL, 'The path where your theme will be created. Defaults to: themes', 'themes')
        ->addOption('starterkit', NULL, InputOption::VALUE_OPTIONAL, 'The theme to use as the starterkit', 'starterkit_theme')
        ->addUsage('custom_theme --name "Custom Theme" --description "Custom theme generated from a starterkit theme" --path themes')
        ->addUsage('custom_theme --name "Custom Theme" --starterkit mystarterkit');
        }
        protected function initialize(InputInterface $input, OutputInterface $output): void {
        if ($input->getOption('name') === NULL) {
        $input->setOption('name', $input->getArgument('machine-name'));
        }
        // Change the directory to the Drupal root.
        chdir($this->root);
        }
        /**
        * {@inheritdoc}
        */
        protected function execute(InputInterface $input, OutputInterface $output): int {
        $io = new SymfonyStyle($input, $output);
        $filesystem = new Filesystem();
        $tmpDir = $this->getUniqueTmpDirPath();
        // Change the directory to the Drupal root.
        chdir($this->root);
        // Path where the generated theme should be placed.
        $destination_theme = $input->getArgument('machine-name');
        $default_destination = 'themes';
        $destination = trim($input->getOption('path') ?: $default_destination, '/') . '/' . $destination_theme;
        $starterkit_id = $input->getOption('starterkit');
        $theme_label = $input->getOption('name');
        $io->writeln("<info>Generating theme $theme_label ($destination_theme) from $starterkit_id starterkit.</info>");
        $destination = trim($input->getOption('path'), '/') . '/' . $destination_theme;
        if (is_dir($destination)) {
        $io->getErrorStyle()->error("Theme could not be generated because the destination directory $destination exists already.");
        return 1;
        }
        // Source directory for the theme.
        $source_theme_name = $input->getOption('starterkit');
        if (!$source_theme = $this->getThemeInfo($source_theme_name)) {
        $io->getErrorStyle()->error("Theme source theme $source_theme_name cannot be found.");
        $starterkit = $this->getThemeInfo($starterkit_id);
        if ($starterkit === NULL) {
        $io->getErrorStyle()->error("Theme source theme $starterkit_id cannot be found.");
        return 1;
        }
        if (!$this->isStarterkitTheme($source_theme)) {
        $io->getErrorStyle()->error("Theme source theme $source_theme_name is not a valid starter kit.");
        return 1;
        }
        $source = $source_theme->getPath();
        if (!is_dir($source)) {
        $io->getErrorStyle()->error("Theme could not be generated because the source directory $source does not exist.");
        return 1;
        $io->writeln("Trying to parse version for $starterkit_id starterkit.", OutputInterface::VERBOSITY_DEBUG);
        try {
        $starterkit_version = self::getStarterKitVersion(
        $starterkit,
        $io
        );
        }
        $tmp_dir = $this->getUniqueTmpDirPath();
        $this->copyRecursive($source, $tmp_dir);
        // Readme is specific to Starterkit, so remove it from the generated theme.
        $readme_file = "$tmp_dir/README.md";
        if (!file_put_contents($readme_file, "$destination_theme theme, generated from $source_theme_name. Additional information on generating themes can be found in the [Starterkit documentation](https://www.drupal.org/docs/core-modules-and-themes/core-themes/starterkit-theme).")) {
        $io->getErrorStyle()->error("The readme could not be rewritten.");
        catch (\Exception $e) {
        $io->getErrorStyle()->error($e->getMessage());
        return 1;
        }
        $io->writeln("Using version $starterkit_version for $starterkit_id starterkit.", OutputInterface::VERBOSITY_DEBUG);
        // Rename files based on the theme machine name.
        $file_pattern = "/$source_theme_name\.(theme|[^.]+\.yml)/";
        if ($files = @scandir($tmp_dir)) {
        foreach ($files as $file) {
        $location = $tmp_dir . '/' . $file;
        if (is_dir($location)) {
        continue;
        $io->writeln("Loading starterkit config from $starterkit_id.starterkit.yml.", OutputInterface::VERBOSITY_DEBUG);
        try {
        $starterkit_config = self::loadStarterKitConfig(
        $starterkit,
        $starterkit_version,
        $theme_label,
        $input->getOption('description')
        );
        }
        if (preg_match($file_pattern, $file, $matches)) {
        if (!rename($location, $tmp_dir . '/' . $destination_theme . '.' . $matches[1])) {
        $io->getErrorStyle()->error("The file $location could not be moved.");
        return 1;
        }
        }
        }
        }
        else {
        $io->getErrorStyle()->error("Temporary directory $tmp_dir cannot be opened.");
        return 1;
        }
        // Info file.
        $info_file = "$tmp_dir/$destination_theme.info.yml";
        if (!file_exists($info_file)) {
        $io->getErrorStyle()->error("The theme info file $info_file could not be read.");
        catch (\Exception $e) {
        $io->getErrorStyle()->error($e->getMessage());
        return 1;
        }
        $filesystem->mkdir($tmpDir);
        $io->writeln("Copying starterkit to temporary directory for processing.", OutputInterface::VERBOSITY_DEBUG);
        $mirror_iterator = (new Finder)
        ->in($starterkit->getPath())
        ->files()
        ->notName($starterkit_config['ignore'])
        ->notPath($starterkit_config['ignore']);
        $filesystem->mirror($starterkit->getPath(), $tmpDir, $mirror_iterator);
        $io->writeln("Modifying and renaming files from starterkit.", OutputInterface::VERBOSITY_DEBUG);
        $patterns = [
        'old' => self::namePatterns($starterkit->getName(), $starterkit->info['name']),
        'new' => self::namePatterns($destination_theme, $theme_label),
        ];
        $filesToEdit = self::createFilesFinder($tmpDir)
        ->contains(array_values($patterns['old']))
        ->notPath($starterkit_config['no_edit']);
        foreach ($filesToEdit as $file) {
        $contents = file_get_contents($file->getRealPath());
        $contents = str_replace($patterns['old'], $patterns['new'], $contents);
        file_put_contents($file->getRealPath(), $contents);
        }
        $filesToRename = self::createFilesFinder($tmpDir)
        ->name(array_map(static fn (string $pattern) => "*$pattern*", array_values($patterns['old'])))
        ->notPath($starterkit_config['no_rename']);
        foreach ($filesToRename as $file) {
        $filepath_segments = explode('/', $file->getRealPath());
        $filename = array_pop($filepath_segments);
        $filename = str_replace($patterns['old'], $patterns['new'], $filename);
        $filepath_segments[] = $filename;
        $filesystem->rename($file->getRealPath(), implode('/', $filepath_segments));
        }
        $io->writeln("Updating $destination_theme.info.yml.", OutputInterface::VERBOSITY_DEBUG);
        $info_file = "$tmpDir/$destination_theme.info.yml";
        $info = Yaml::decode(file_get_contents($info_file));
        $info['name'] = $input->getOption('name') ?: $destination_theme;
        $info['core_version_requirement'] = '^' . $this->getVersion();
        if (!array_key_exists('version', $info)) {
        $confirm_versionless_source_theme = new ConfirmationQuestion(sprintf('The source theme %s does not have a version specified. This makes tracking changes in the source theme difficult. Are you sure you want to continue?', $source_theme->getName()));
        if (!$io->askQuestion($confirm_versionless_source_theme)) {
        return 0;
        }
        }
        $source_version = $info['version'] ?? 'unknown-version';
        if ($source_version === 'VERSION') {
        $source_version = \Drupal::VERSION;
        }
        // A version in the generator string like "9.4.0-dev" is not very helpful.
        // When this occurs, generate a version string that points to a commit.
        if (VersionParser::parseStability($source_version) === 'dev') {
        $git_check = Process::fromShellCommandline('git --help');
        $git_check->run();
        if ($git_check->getExitCode()) {
        $io->error(sprintf('The source theme %s has a development version number (%s). Determining a specific commit is not possible because git is not installed. Either install git or use a tagged release to generate a theme.', $source_theme->getName(), $source_version));
        return 1;
        }
        // Get the git commit for the source theme.
        $git_get_commit = Process::fromShellCommandline("git rev-list --max-count=1 --abbrev-commit HEAD -C $source");
        $git_get_commit->run();
        if ($git_get_commit->getOutput() === '') {
        $confirm_packaged_dev_release = new ConfirmationQuestion(sprintf('The source theme %s has a development version number (%s). Because it is not a git checkout, a specific commit could not be identified. This makes tracking changes in the source theme difficult. Are you sure you want to continue?', $source_theme->getName(), $source_version));
        if (!$io->askQuestion($confirm_packaged_dev_release)) {
        return 0;
        }
        $source_version .= '#unknown-commit';
        }
        else {
        $source_version .= '#' . trim($git_get_commit->getOutput());
        }
        }
        $info['generator'] = "$source_theme_name:$source_version";
        if ($description = $input->getOption('description')) {
        $info['description'] = $description;
        }
        else {
        unset($info['description']);
        }
        // Replace references to libraries.
        if (isset($info['libraries'])) {
        $info['libraries'] = preg_replace("/$source_theme_name(\/.*)/", "$destination_theme$1", $info['libraries']);
        }
        if (isset($info['libraries-extend'])) {
        foreach ($info['libraries-extend'] as $key => $value) {
        $info['libraries-extend'][$key] = preg_replace("/$source_theme_name(\/.*)/", "$destination_theme$1", $info['libraries-extend'][$key]);
        }
        }
        if (isset($info['libraries-override'])) {
        foreach ($info['libraries-override'] as $key => $value) {
        if (isset($info['libraries-override'][$key]['dependencies'])) {
        $info['libraries-override'][$key]['dependencies'] = preg_replace("/$source_theme_name(\/.*)/", "$destination_theme$1", $info['libraries-override'][$key]['dependencies']);
        }
        }
        }
        if (!file_put_contents($info_file, Yaml::encode($info))) {
        $io->getErrorStyle()->error("The theme info file $info_file could not be written.");
        return 1;
        }
        // Replace references to libraries in libraries.yml file.
        $libraries_file = "$tmp_dir/$destination_theme.libraries.yml";
        if (file_exists($libraries_file)) {
        $libraries = Yaml::decode(file_get_contents($libraries_file));
        foreach ($libraries as $key => $value) {
        if (isset($libraries[$key]['dependencies'])) {
        $libraries[$key]['dependencies'] = preg_replace("/$source_theme_name(\/.*)/", "$destination_theme$1", $libraries[$key]['dependencies']);
        }
        }
        if (!file_put_contents($libraries_file, Yaml::encode($libraries))) {
        $io->getErrorStyle()->error("The libraries file $libraries_file could not be written.");
        return 1;
        }
        }
        // Rename hooks.
        $theme_file = "$tmp_dir/$destination_theme.theme";
        if (file_exists($theme_file)) {
        if (!file_put_contents($theme_file, preg_replace("/(function )($source_theme_name)(_.*)/", "$1$destination_theme$3", file_get_contents($theme_file)))) {
        $io->getErrorStyle()->error("The theme file $theme_file could not be written.");
        return 1;
        }
        }
        // Rename references to libraries in templates.
        $iterator = new TemplateDirIterator(new \RegexIterator(
        new \RecursiveIteratorIterator(
        new \RecursiveDirectoryIterator($tmp_dir), \RecursiveIteratorIterator::LEAVES_ONLY
        ), '/' . preg_quote('.html.twig') . '$/'
        ));
        foreach ($iterator as $template_file => $contents) {
        $new_template_content = preg_replace("/(attach_library\(['\")])$source_theme_name(\/.*['\"]\))/", "$1$destination_theme$2", $contents);
        if (!file_put_contents($template_file, $new_template_content)) {
        $io->getErrorStyle()->error("The template file $template_file could not be written.");
        return 1;
        }
        }
        $info = array_filter(
        array_merge($info, $starterkit_config['info']),
        static fn (mixed $value) => $value !== NULL,
        );
        // Ensure the generated theme is not hidden.
        unset($info['hidden']);
        file_put_contents($info_file, Yaml::encode($info));
        $loader = new ClassLoader();
        $loader->addPsr4("Drupal\\$source_theme_name\\", "$source/src");
        $loader->addPsr4("Drupal\\{$starterkit->getName()}\\", "{$starterkit->getPath()}/src");
        $loader->register();
        $generator_classname = "Drupal\\$source_theme_name\\StarterKit";
        $generator_classname = "Drupal\\{$starterkit->getName()}\\StarterKit";
        if (class_exists($generator_classname)) {
        if (is_a($generator_classname, StarterKitInterface::class, TRUE)) {
        $generator_classname::postProcess($tmp_dir, $destination_theme, $info['name']);
        $io->writeln("Running post processing.", OutputInterface::VERBOSITY_DEBUG);
        $generator_classname::postProcess($tmpDir, $destination_theme, $theme_label);
        }
        else {
        $io->getErrorStyle()->error("The $generator_classname does not implement \Drupal\Core\Theme\StarterKitInterface and cannot perform post-processing.");
        return 1;
        }
        }
        if (!@rename($tmp_dir, $destination)) {
        // If rename fails, copy the files to the destination directory. This is
        // expected to happen when the tmp directory is on a different file
        // system.
        $this->copyRecursive($tmp_dir, $destination);
        // Renaming would not have left anything behind. Ensure that is still the
        // case.
        $this->rmRecursive($tmp_dir);
        else {
        $io->writeln("Skipping post processing, $generator_classname not defined.", OutputInterface::VERBOSITY_DEBUG);
        }
        $output->writeln(sprintf('Theme generated successfully to %s', $destination));
        // Move altered theme to final destination.
        $io->writeln("Copying $destination_theme to $destination.", OutputInterface::VERBOSITY_DEBUG);
        $filesystem->mirror($tmpDir, $destination);
        $io->writeln(sprintf('Theme generated successfully to %s', $destination));
        return 0;
        }
        /**
        * Removes a directory recursively.
        * Generates a path to a temporary location.
        *
        * @param string $dir
        * A directory to be removed.
        * @return string
        */
        private function rmRecursive(string $dir): void {
        $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST);
        foreach ($files as $file) {
        is_dir($file) ? rmdir($file) : unlink($file);
        }
        private function getUniqueTmpDirPath(): string {
        return sys_get_temp_dir() . '/drupal-starterkit-theme-' . uniqid(md5(microtime()), TRUE);
        }
        /**
        * Copies files recursively.
        * Gets theme info using the theme name.
        *
        * @param string $src
        * A file or directory to be copied.
        * @param string $dest
        * Destination directory where the directory or file should be copied.
        * @param string $theme_name
        * The machine name of the theme.
        *
        * @throws \RuntimeException
        * Exception thrown if copying failed.
        * @return \Drupal\Core\Extension\Extension|null
        */
        private function copyRecursive($src, $dest): void {
        // Copy all subdirectories and files.
        if (is_dir($src)) {
        if (!mkdir($dest, FileSystem::CHMOD_DIRECTORY, FALSE)) {
        throw new \RuntimeException("Directory $dest could not be created");
        private function getThemeInfo(string $theme_name): ? Extension {
        $extension_discovery = new ExtensionDiscovery($this->root, FALSE, []);
        $themes = $extension_discovery->scan('theme');
        $theme = $themes[$theme_name] ?? NULL;
        if ($theme !== NULL) {
        $theme->info = (new InfoParser($this->root))->parse($theme->getPathname());
        }
        $handle = opendir($src);
        while ($file = readdir($handle)) {
        if ($file != "." && $file != "..") {
        $this->copyRecursive("$src/$file", "$dest/$file");
        return $theme;
        }
        private static function createFilesFinder(string $dir): Finder {
        return (new Finder)->in($dir)->files();
        }
        closedir($handle);
        private static function loadStarterKitConfig(
        Extension $theme,
        string $version,
        string $name,
        string $description
        ): array {
        $starterkit_config_file = $theme->getPath() . '/' . $theme->getName() . '.starterkit.yml';
        if (!file_exists($starterkit_config_file)) {
        throw new \RuntimeException("Theme source theme {$theme->getName()} is not a valid starter kit.");
        }
        elseif (is_link($src)) {
        symlink(readlink($src), $dest);
        $starterkit_config_defaults = [
        'info' => [
        'name' => $name,
        'description' => $description,
        'core_version_requirement' => '^' . explode('.', \Drupal::VERSION)[0],
        'version' => '1.0.0',
        'generator' => "{$theme->getName()}:$version",
        ],
        'ignore' => [
        '/src/StarterKit.php',
        '/*.starterkit.yml',
        ],
        'no_edit' => [],
        'no_rename' => [],
        ];
        $starterkit_config = Yaml::decode(file_get_contents($starterkit_config_file));
        if (!is_array($starterkit_config)) {
        throw new \RuntimeException('Starterkit config is was not able to be parsed.');
        }
        elseif (!copy($src, $dest)) {
        throw new \RuntimeException("File $src could not be copied to $dest");
        if (!isset($starterkit_config['info'])) {
        $starterkit_config['info'] = [];
        }
        $starterkit_config['info'] = array_merge($starterkit_config_defaults['info'], $starterkit_config['info']);
        // Set permissions for the directory or file.
        if (!is_link($dest)) {
        if (is_dir($dest)) {
        $mode = FileSystem::CHMOD_DIRECTORY;
        foreach (['ignore', 'no_edit', 'no_rename'] as $key) {
        if (!isset($starterkit_config[$key])) {
        $starterkit_config[$key] = $starterkit_config_defaults[$key];
        }
        else {
        $mode = FileSystem::CHMOD_FILE;
        if (!is_array($starterkit_config[$key])) {
        throw new \RuntimeException("$key in starterkit.yml must be an array");
        }
        $starterkit_config[$key] = array_map(
        static fn (string $path) => Glob::toRegex(trim($path, '/')),
        $starterkit_config[$key]
        );
        if (!chmod($dest, $mode)) {
        throw new \RuntimeException("The file permissions could not be set on $src");
        if (count($starterkit_config[$key]) > 0) {
        $files = self::createFilesFinder($theme->getPath())->path($starterkit_config[$key]);
        $starterkit_config[$key] = array_map(static fn ($file) => $file->getRelativePathname(), iterator_to_array($files));
        if (count($starterkit_config[$key]) === 0) {
        throw new \RuntimeException("Paths were defined `$key` but no files found.");
        }
        }
        }
        /**
        * Generates a path to a temporary location.
        *
        * @return string
        */
        private function getUniqueTmpDirPath(): string {
        return sys_get_temp_dir() . '/drupal-starterkit-theme-' . uniqid(md5(microtime()), TRUE);
        return $starterkit_config;
        }
        /**
        * Gets theme info using the theme name.
        *
        * @param string $theme
        * The machine name of the theme.
        *
        * @return \Drupal\Core\Extension\Extension|null
        */
        private function getThemeInfo(string $theme): ? Extension {
        $extension_discovery = new ExtensionDiscovery($this->root, FALSE, []);
        $themes = $extension_discovery->scan('theme');
        if (!isset($themes[$theme])) {
        return NULL;
        private static function getStarterKitVersion(
        Extension $theme,
        SymfonyStyle $io
        ): string {
        $source_version = $theme->info['version'] ?? '';
        if ($source_version === '') {
        $confirm = new ConfirmationQuestion(sprintf(
        'The source theme %s does not have a version specified. This makes tracking changes in the source theme difficult. Are you sure you want to continue?',
        $theme->getName()
        ));
        if (!$io->askQuestion($confirm)) {
        throw new \RuntimeException('source version could not be determined');
        }
        return $themes[$theme];
        $source_version = 'unknown-version';
        }
        if ($source_version === 'VERSION') {
        $source_version = \Drupal::VERSION;
        }
        /**
        * Checks if the theme is a starterkit theme.
        *
        * @param \Drupal\Core\Extension\Extension $theme
        * The theme extension.
        *
        * @return bool
        */
        private function isStarterkitTheme(Extension $theme): bool {
        $info_parser = new InfoParser($this->root);
        $info = $info_parser->parse($theme->getPathname());
        // A version in the generator string like "9.4.0-dev" is not very helpful.
        // When this occurs, generate a version string that points to a commit.
        if (VersionParser::parseStability($source_version) === 'dev') {
        $git_check = Process::fromShellCommandline('git --help');
        $git_check->run();
        if ($git_check->getExitCode()) {
        throw new \RuntimeException(
        sprintf(
        'The source theme %s has a development version number (%s). Determining a specific commit is not possible because git is not installed. Either install git or use a tagged release to generate a theme.',
        $theme->getName(),
        $source_version
        )
        );
        }
        return $info['starterkit'] ?? FALSE === TRUE;
        // Get the git commit for the source theme.
        $git_get_commit = Process::fromShellCommandline("git rev-list --max-count=1 --abbrev-commit HEAD -C {$theme->getPath()}");
        $git_get_commit->run();
        if (!$git_get_commit->isSuccessful() || $git_get_commit->getOutput() === '') {
        $confirm = new ConfirmationQuestion(sprintf(
        'The source theme %s has a development version number (%s). Because it is not a git checkout, a specific commit could not be identified. This makes tracking changes in the source theme difficult. Are you sure you want to continue?',
        $theme->getName(),
        $source_version
        ));
        if (!$io->askQuestion($confirm)) {
        throw new \RuntimeException('source version could not be determined');
        }
        $source_version .= '#unknown-commit';
        }
        else {
        $source_version .= '#' . trim($git_get_commit->getOutput());
        }
        }
        return $source_version;
        }
        /**
        * Gets the current Drupal major version.
        *
        * @return string
        */
        private function getVersion(): string {
        return explode('.', \Drupal::VERSION)[0];
        private static function namePatterns(string $machine_name, string $label): array {
        return [
        'machine_name' => $machine_name,
        'machine_name_camel' => u($machine_name)->camel(),
        'machine_name_pascal' => u($machine_name)->camel()->title(),
        'machine_name_title' => u($machine_name)->title(),
        'label' => $label,
        'label_camel' => u($label)->camel(),
        'label_pascal' => u($label)->camel()->title(),
        'label_title' => u($label)->title(),
        ];
        }
        }
        ......@@ -2,11 +2,14 @@
        declare(strict_types=1);
        namespace Drupal\Tests\Core\Command;
        namespace Drupal\BuildTests\Command;
        use Drupal\BuildTests\QuickStart\QuickStartTestBase;
        use Drupal\Core\Command\GenerateTheme;
        use Drupal\Core\Serialization\Yaml;
        use Drupal\sqlite\Driver\Database\sqlite\Install\Tasks;
        use Symfony\Component\Console\Tester\CommandTester;
        use Symfony\Component\Console\Tester\Constraint\CommandIsSuccessful;
        use Symfony\Component\Process\PhpExecutableFinder;
        use Symfony\Component\Process\Process;
        ......@@ -93,7 +96,7 @@ public function test() {
        $process = $this->generateThemeFromStarterkit();
        $result = $process->run();
        $this->assertEquals('Theme generated successfully to themes/test_custom_theme', trim($process->getOutput()), $process->getErrorOutput());
        $this->assertStringContainsString('Theme generated successfully to themes/test_custom_theme', trim($process->getOutput()), $process->getErrorOutput());
        $this->assertSame(0, $result);
        $theme_path_relative = 'themes/test_custom_theme';
        ......@@ -104,7 +107,7 @@ public function test() {
        // Confirm readme is rewritten.
        $readme_file = $this->getWorkspaceDirectory() . "/$theme_path_relative/README.md";
        $this->assertSame('test_custom_theme theme, generated from starterkit_theme. Additional information on generating themes can be found in the [Starterkit documentation](https://www.drupal.org/docs/core-modules-and-themes/core-themes/starterkit-theme).', file_get_contents($readme_file));
        $this->assertSame('"Test custom starterkit theme" theme, generated from starterkit_theme. Additional information on generating themes can be found in the [Starterkit documentation](https://www.drupal.org/docs/core-modules-and-themes/core-themes/starterkit-theme).', file_get_contents($readme_file));
        // Ensure that the generated theme can be installed.
        $this->installQuickStart('minimal');
        ......@@ -141,8 +144,18 @@ public function testGeneratingFromAnotherTheme() {
        $process = $this->generateThemeFromStarterkit();
        $exit_code = $process->run();
        $this->assertSame('Theme generated successfully to themes/test_custom_theme', trim($process->getOutput()), $process->getErrorOutput());
        $this->assertStringContainsString('Theme generated successfully to themes/test_custom_theme', trim($process->getOutput()), $process->getErrorOutput());
        $this->assertSame(0, $exit_code);
        file_put_contents($this->getWorkspaceDirectory() . '/themes/test_custom_theme/test_custom_theme.starterkit.yml', <<<YAML
        delete: []
        no_edit: []
        no_rename: []
        info:
        version: 1.0.0
        YAML
        );
        $install_command = [
        $this->php,
        'core/scripts/drupal',
        ......@@ -154,12 +167,12 @@ public function testGeneratingFromAnotherTheme() {
        ];
        $process = new Process($install_command);
        $exit_code = $process->run();
        $this->assertSame('Theme generated successfully to themes/generated_from_another_theme', trim($process->getOutput()), $process->getErrorOutput());
        $this->assertStringContainsString('Theme generated successfully to themes/generated_from_another_theme', trim($process->getOutput()), $process->getErrorOutput());
        $this->assertSame(0, $exit_code);
        // Confirm readme is rewritten.
        $readme_file = $this->getWorkspaceDirectory() . '/themes/generated_from_another_theme/README.md';
        $this->assertSame('generated_from_another_theme theme, generated from test_custom_theme. Additional information on generating themes can be found in the [Starterkit documentation](https://www.drupal.org/docs/core-modules-and-themes/core-themes/starterkit-theme).', file_get_contents($readme_file));
        // Confirm new .theme file.
        $dot_theme_file = $this->getWorkspaceDirectory() . '/themes/generated_from_another_theme/generated_from_another_theme.theme';
        $this->assertStringContainsString('function generated_from_another_theme_preprocess_image_widget(array &$variables) {', file_get_contents($dot_theme_file));
        }
        /**
        ......@@ -175,7 +188,7 @@ public function testDevSnapshot() {
        $process = $this->generateThemeFromStarterkit();
        $result = $process->run();
        $this->assertEquals('Theme generated successfully to themes/test_custom_theme', trim($process->getOutput()), $process->getErrorOutput());
        $this->assertStringContainsString('Theme generated successfully to themes/test_custom_theme', trim($process->getOutput()), $process->getErrorOutput());
        $this->assertSame(0, $result);
        $theme_path_relative = 'themes/test_custom_theme';
        ......@@ -198,7 +211,7 @@ public function testContribStarterkit(): void {
        $process = $this->generateThemeFromStarterkit();
        $result = $process->run();
        $this->assertEquals('Theme generated successfully to themes/test_custom_theme', trim($process->getOutput()), $process->getErrorOutput());
        $this->assertStringContainsString('Theme generated successfully to themes/test_custom_theme', trim($process->getOutput()), $process->getErrorOutput());
        $this->assertSame(0, $result);
        $info = $this->assertThemeExists('themes/test_custom_theme');
        self::assertArrayNotHasKey('hidden', $info);
        ......@@ -224,7 +237,7 @@ public function testContribStarterkitDevSnapshot(): void {
        $process = $this->generateThemeFromStarterkit();
        $result = $process->run();
        $this->assertEquals("The source theme starterkit_theme has a development version number (7.x-dev). Because it is not a git checkout, a specific commit could not be identified. This makes tracking changes in the source theme difficult. Are you sure you want to continue? (yes/no) [yes]:\n > Theme generated successfully to themes/test_custom_theme", trim($process->getOutput()), $process->getErrorOutput());
        $this->assertStringContainsString("The source theme starterkit_theme has a development version number (7.x-dev). Because it is not a git checkout, a specific commit could not be identified. This makes tracking changes in the source theme difficult. Are you sure you want to continue? (yes/no) [yes]:\n > Theme generated successfully to themes/test_custom_theme", trim($process->getOutput()), $process->getErrorOutput());
        $this->assertSame(0, $result);
        $info = $this->assertThemeExists('themes/test_custom_theme');
        self::assertArrayNotHasKey('hidden', $info);
        ......@@ -279,7 +292,7 @@ public function testContribStarterkitDevSnapshotWithGitNotInstalled(): void {
        $process = $this->generateThemeFromStarterkit($env);
        $result = $process->run();
        $this->assertEquals("[ERROR] The source theme starterkit_theme has a development version number \n (7.x-dev). Determining a specific commit is not possible because git is\n not installed. Either install git or use a tagged release to generate a\n theme.", trim($process->getOutput()), $process->getErrorOutput());
        $this->assertEquals("[ERROR] The source theme starterkit_theme has a development version number \n (7.x-dev). Determining a specific commit is not possible because git is\n not installed. Either install git or use a tagged release to generate a\n theme.", trim($process->getErrorOutput()), $process->getErrorOutput());
        $this->assertSame(1, $result);
        $this->assertFileDoesNotExist($this->getWorkspaceDirectory() . "/themes/test_custom_theme");
        }
        ......@@ -296,7 +309,7 @@ public function testCustomStarterkit(): void {
        $process = $this->generateThemeFromStarterkit();
        $result = $process->run();
        $this->assertEquals("The source theme starterkit_theme does not have a version specified. This makes tracking changes in the source theme difficult. Are you sure you want to continue? (yes/no) [yes]:\n > Theme generated successfully to themes/test_custom_theme", trim($process->getOutput()), $process->getErrorOutput());
        $this->assertStringContainsString("The source theme starterkit_theme does not have a version specified. This makes tracking changes in the source theme difficult. Are you sure you want to continue? (yes/no) [yes]:\n > Theme generated successfully to themes/test_custom_theme", trim($process->getOutput()), $process->getErrorOutput());
        $this->assertSame(0, $result);
        $info = $this->assertThemeExists('themes/test_custom_theme');
        self::assertArrayNotHasKey('hidden', $info);
        ......@@ -364,4 +377,226 @@ public function testStarterKitFlag(): void {
        $this->assertSame(1, $result);
        }
        public function testDeleteDirectory(): void {
        $this->writeStarterkitConfig([
        'ignore' => [
        '/src/*',
        '/starterkit_theme.starterkit.yml',
        ],
        ]);
        $tester = $this->runCommand(
        [
        'machine-name' => 'test_custom_theme',
        '--name' => 'Test custom starterkit theme',
        '--description' => 'Custom theme generated from a starterkit theme',
        ]
        );
        $tester->assertCommandIsSuccessful($tester->getErrorOutput());
        $this->assertThemeExists('themes/test_custom_theme');
        $theme_path_absolute = $this->getWorkspaceDirectory() . '/themes/test_custom_theme';
        self::assertDirectoryExists($theme_path_absolute);
        self::assertFileDoesNotExist($theme_path_absolute . '/src/StarterKit.php');
        self::assertDirectoryDoesNotExist($theme_path_absolute . '/src');
        }
        public function testNoEditMissingFilesWarning(): void {
        $this->writeStarterkitConfig([
        'no_edit' => [
        '/js/starterkit_theme.js',
        ],
        ]);
        $tester = $this->runCommand(
        [
        'machine-name' => 'test_custom_theme',
        '--name' => 'Test custom starterkit theme',
        '--description' => 'Custom theme generated from a starterkit theme',
        ]
        );
        self::assertThat($tester->getStatusCode(), self::logicalNot(new CommandIsSuccessful()), trim($tester->getDisplay()));
        self::assertEquals('[ERROR] Paths were defined `no_edit` but no files found.', trim($tester->getErrorOutput()));
        $theme_path_absolute = $this->getWorkspaceDirectory() . '/themes/test_custom_theme';
        self::assertDirectoryDoesNotExist($theme_path_absolute);
        }
        public function testNoRenameMissingFilesWarning(): void {
        $this->writeStarterkitConfig([
        'no_rename' => [
        '/js/starterkit_theme.js',
        ],
        ]);
        $tester = $this->runCommand(
        [
        'machine-name' => 'test_custom_theme',
        '--name' => 'Test custom starterkit theme',
        '--description' => 'Custom theme generated from a starterkit theme',
        ]
        );
        self::assertThat($tester->getStatusCode(), self::logicalNot(new CommandIsSuccessful()), trim($tester->getDisplay()));
        self::assertEquals('[ERROR] Paths were defined `no_rename` but no files found.', trim($tester->getErrorOutput()));
        $theme_path_absolute = $this->getWorkspaceDirectory() . '/themes/test_custom_theme';
        self::assertDirectoryDoesNotExist($theme_path_absolute);
        }
        public function testNoRename(): void {
        $this->writeStarterkitConfig([
        'no_rename' => [
        'js/starterkit_theme.js',
        '**/js/*.js',
        'js/**/*.js',
        ],
        ]);
        mkdir($this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/js');
        mkdir($this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/js/baz');
        file_put_contents($this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/js/starterkit_theme.js', '');
        file_put_contents($this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/js/starterkit_theme.foo.js', '');
        file_put_contents($this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/js/baz/starterkit_theme.bar.js', '');
        $tester = $this->runCommand(
        [
        'machine-name' => 'test_custom_theme',
        '--name' => 'Test custom starterkit theme',
        '--description' => 'Custom theme generated from a starterkit theme',
        ]
        );
        $tester->assertCommandIsSuccessful($tester->getErrorOutput());
        $this->assertThemeExists('themes/test_custom_theme');
        $theme_path_absolute = $this->getWorkspaceDirectory() . '/themes/test_custom_theme';
        self::assertFileExists($theme_path_absolute . '/js/starterkit_theme.js');
        self::assertFileExists($theme_path_absolute . '/js/starterkit_theme.foo.js');
        self::assertFileExists($theme_path_absolute . '/js/baz/starterkit_theme.bar.js');
        }
        public function testNoEdit(): void {
        $this->writeStarterkitConfig([
        'no_edit' => [
        '*no_edit_*',
        ],
        ]);
        $fixture = <<<FIXTURE
        # machine_name
        starterkit_theme
        # label
        Starterkit theme
        # machine_class_name
        StarterkitTheme
        # label_class_name
        StarterkitTheme
        FIXTURE;
        file_put_contents($this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/edit_fixture.txt', $fixture);
        file_put_contents($this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/no_edit_fixture.txt', $fixture);
        file_put_contents($this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/src/StarterkitThemePreRender.php', <<<PHP
        <?php
        namespace Drupal\starterkit_theme;
        use Drupal\Core\Security\TrustedCallbackInterface;
        /**
        * Implements trusted prerender callbacks for the Starterkit theme.
        *
        * @internal
        */
        class StarterkitThemePreRender implements TrustedCallbackInterface {
        }
        PHP);
        $tester = $this->runCommand(
        [
        'machine-name' => 'test_custom_theme',
        '--name' => 'Test custom starterkit theme',
        '--description' => 'Custom theme generated from a starterkit theme',
        ]
        );
        $tester->assertCommandIsSuccessful($tester->getErrorOutput());
        $this->assertThemeExists('themes/test_custom_theme');
        $theme_path_absolute = $this->getWorkspaceDirectory() . '/themes/test_custom_theme';
        self::assertFileExists($theme_path_absolute . '/no_edit_fixture.txt');
        self::assertEquals($fixture, file_get_contents($theme_path_absolute . '/no_edit_fixture.txt'));
        self::assertFileExists($theme_path_absolute . '/edit_fixture.txt');
        self::assertEquals(<<<EDITED
        # machine_name
        test_custom_theme
        # label
        Test custom starterkit theme
        # machine_class_name
        TestCustomTheme
        # label_class_name
        TestCustomTheme
        EDITED, file_get_contents($theme_path_absolute . '/edit_fixture.txt'));
        self::assertEquals(<<<EDITED
        <?php
        namespace Drupal\\test_custom_theme;
        use Drupal\Core\Security\TrustedCallbackInterface;
        /**
        * Implements trusted prerender callbacks for the Test custom starterkit theme.
        *
        * @internal
        */
        class TestCustomThemePreRender implements TrustedCallbackInterface {
        }
        EDITED, file_get_contents($theme_path_absolute . '/src/TestCustomThemePreRender.php'));
        }
        public function testInfoOverrides(): void {
        // Force `base theme` to be `false.
        $starterkit_info_yml = $this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/starterkit_theme.info.yml';
        $info = Yaml::decode(file_get_contents($starterkit_info_yml));
        $info['base theme'] = FALSE;
        file_put_contents($starterkit_info_yml, Yaml::encode($info));
        $this->writeStarterkitConfig([
        'info' => [
        'libraries' => [
        'core/jquery',
        ],
        ],
        ]);
        $tester = $this->runCommand(
        [
        'machine-name' => 'test_custom_theme',
        '--name' => 'Test custom starterkit theme',
        '--description' => 'Custom theme generated from a starterkit theme',
        ]
        );
        $tester->assertCommandIsSuccessful($tester->getErrorOutput());
        $info = $this->assertThemeExists('themes/test_custom_theme');
        self::assertArrayHasKey('base theme', $info);
        self::assertFalse($info['base theme']);
        self::assertArrayHasKey('libraries', $info);
        self::assertEquals(['core/jquery'], $info['libraries']);
        }
        private function writeStarterkitConfig(array $config): void {
        $starterkit_yml = $this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/starterkit_theme.starterkit.yml';
        $starterkit_config = Yaml::decode(file_get_contents($starterkit_yml));
        $starterkit_config = array_replace_recursive($starterkit_config, $config);
        file_put_contents($starterkit_yml, Yaml::encode($starterkit_config));
        }
        private function runCommand(array $input): CommandTester {
        $tester = new CommandTester(new GenerateTheme(NULL, $this->getWorkspaceDirectory()));
        $tester->execute($input, [
        'capture_stderr_separately' => TRUE,
        ]);
        return $tester;
        }
        }
        ......@@ -2,7 +2,6 @@
        namespace Drupal\starterkit_theme;
        use Drupal\Component\Serialization\Yaml;
        use Drupal\Core\Theme\StarterKitInterface;
        final class StarterKit implements StarterKitInterface {
        ......@@ -11,10 +10,12 @@ final class StarterKit implements StarterKitInterface {
        * {@inheritdoc}
        */
        public static function postProcess(string $working_dir, string $machine_name, string $theme_name): void {
        $info_file = "$working_dir/$machine_name.info.yml";
        $info = Yaml::decode(file_get_contents($info_file));
        unset($info['hidden']);
        file_put_contents($info_file, Yaml::encode($info));
        $readme_file = "$working_dir/README.md";
        try {
        file_put_contents($readme_file, "$theme_name theme, generated from starterkit_theme. Additional information on generating themes can be found in the [Starterkit documentation](https://www.drupal.org/docs/core-modules-and-themes/core-themes/starterkit-theme).");
        }
        catch (\Throwable $th) {
        }
        }
        }
        name: starterkit_theme
        name: Starterkit theme
        type: theme
        'base theme': stable9
        hidden: true
        starterkit: true
        version: VERSION
        libraries:
        - starterkit_theme/base
        ......
        ignore:
        - '/src/StarterKit.php'
        - '/starterkit_theme.starterkit.yml'
        no_edit: []
        no_rename: []
        info:
        version: 1.0.0
        0% Loading or .
        You are about to add 0 people to the discussion. Proceed with caution.
        Please register or to comment