Loading core/lib/Drupal/Core/Command/GenerateTheme.php +35 −2 Original line number Diff line number Diff line Loading @@ -148,22 +148,55 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'old' => self::namePatterns($starterkit->getName(), $starterkit->info['name']), 'new' => self::namePatterns($destination_theme, $theme_label), ]; // Generate unique placeholders for each pattern key in $patterns['old'] so // we can perform a two-pass replacement without collisions. This avoids // cases where a replacement introduces text that matches another pattern // (or where short labels like "Simple" overlap with class-name fragments), // which would otherwise cause accidental double replacements. $placeholders = []; foreach (array_keys($patterns['old']) as $key) { $placeholders[$key] = '__' . strtoupper($key) . '__'; } $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); // Step 1: Replace old patterns with placeholders in content. $contents = str_replace($patterns['old'], $placeholders, $contents); // Step 2: Replace placeholders with new patterns in content. $contents = str_replace($placeholders, $patterns['new'], $contents); // Normalize comment marker driven replacements. When the source theme // uses very short labels (for example "Simple") the generic // replacements above can map the text to the wrong placeholder because // both the label and class name fragments share the same literal value. // Unique, explicit marker lines make the intent unambiguous, so update // those lines after the generic pass. $markerReplacements = [ '#@starterkit:machine_name' => $patterns['new']['machine_name'], '#@starterkit:label' => $patterns['new']['label'], '#@starterkit:machine_class_name' => $patterns['new']['machine_name_pascal'], '#@starterkit:label_class_name' => $patterns['new']['machine_name_pascal'], ]; foreach ($markerReplacements as $marker => $replacement) { $pattern = '/(^\s*' . preg_quote($marker, '/') . '\s*\R)([^\r\n]*)/m'; $contents = preg_replace($pattern, '$1' . $replacement, $contents); } file_put_contents($file->getRealPath(), $contents); } // Step 3: Repeat for file renaming. $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); $filename = str_replace($patterns['old'], $placeholders, $filename); $filename = str_replace($placeholders, $patterns['new'], $filename); $filepath_segments[] = $filename; $filesystem->rename($file->getRealPath(), implode('/', $filepath_segments)); } Loading core/tests/Drupal/BuildTests/Command/GenerateThemeTest.php +112 −8 Original line number Diff line number Diff line Loading @@ -45,6 +45,110 @@ protected function setUp(): void { chdir($this->getWorkingPath()); } /** * Tests generating theme from a simple named Starterkit enabled theme. */ public function testSimpleStarterkitTheme(): void { $starterkit_theme_path_relative = 'themes/simple'; $starterkit_theme_path_absolute = $this->getWorkspaceDirectory() . '/' . $starterkit_theme_path_relative; mkdir($starterkit_theme_path_absolute); file_put_contents($starterkit_theme_path_absolute . '/simple.info.yml', Yaml::encode([ 'name' => 'Simple', 'type' => 'theme', 'base theme' => FALSE, 'core_version_requirement' => '*', ])); file_put_contents($starterkit_theme_path_absolute . '/simple.starterkit.yml', Yaml::encode([ 'ignore' => ['/simple.starterkit.yml'], 'no_edit' => [], 'no_rename' => [], 'info' => [ 'version' => '1.0.0', ], ])); mkdir($starterkit_theme_path_absolute . '/src'); file_put_contents($starterkit_theme_path_absolute . '/src/SimpleUtility.php', <<<PHP <?php namespace Drupal\simple; final class SimpleUtility { public const MACHINE_NAME = 'simple'; public const MACHINE_CLASS_NAME = 'Simple'; } PHP); file_put_contents($starterkit_theme_path_absolute . '/simple.theme', <<<PHP <?php /** * @file * Drupal theme functions for Simple. */ function simple_preprocess_html(array &\$variables): void { \$variables['#attached']['drupalSettings']['simple'] = 'simple'; } PHP); $fixture = <<<FIXTURE #@starterkit:machine_name simple #@starterkit:label A Simple Theme #@starterkit:machine_class_name Simple #@starterkit:label_class_name Simple FIXTURE; file_put_contents($starterkit_theme_path_absolute . '/README.md', $fixture); $this->assertFileExists($starterkit_theme_path_absolute . '/simple.info.yml'); $this->assertThemeExists($starterkit_theme_path_relative); $tester = $this->runCommand([ 'machine-name' => 'simple_theme', '--name' => 'My Simple Theme', '--description' => 'Custom theme generated from a Simple Starterkit theme', '--starterkit' => 'simple', ]); $tester->assertCommandIsSuccessful(); $theme_path_relative = 'themes/simple_theme'; $this->assertThemeExists($theme_path_relative); $info = $this->assertThemeExists($theme_path_relative); self::assertEquals('My Simple Theme', $info['name']); $readme_file = $this->getWorkspaceDirectory() . "/$theme_path_relative/README.md"; $this->assertFileExists($readme_file); $fixture = <<<FIXTURE #@starterkit:machine_name simple_theme #@starterkit:label My Simple Theme #@starterkit:machine_class_name SimpleTheme #@starterkit:label_class_name SimpleTheme FIXTURE; $this->assertSame($fixture, file_get_contents($readme_file)); // The .theme file should be renamed and contain updated machine and label // values. $dot_theme_path = $this->getWorkspaceDirectory() . "/$theme_path_relative/simple_theme.theme"; $this->assertFileExists($dot_theme_path); $dot_theme_contents = file_get_contents($dot_theme_path); self::assertStringContainsString("\$variables['#attached']['drupalSettings']['simple_theme']", $dot_theme_contents); // Ensure content replacements respect the namespace and class fragments. $utility_file = $this->getWorkspaceDirectory() . "/$theme_path_relative/src/SimpleThemeUtility.php"; $this->assertFileExists($utility_file); $utility_contents = file_get_contents($utility_file); self::assertStringContainsString('namespace Drupal\\simple_theme;', $utility_contents); self::assertStringContainsString('class SimpleThemeUtility', $utility_contents); self::assertStringContainsString("public const MACHINE_NAME = 'simple_theme'", $utility_contents); self::assertStringContainsString("public const MACHINE_CLASS_NAME = 'SimpleTheme'", $utility_contents); } /** * Generates PHP process to generate a theme from core's starterkit theme. * Loading Loading @@ -478,13 +582,13 @@ public function testNoEdit(): void { ], ]); $fixture = <<<FIXTURE # machine_name #@starterkit:machine_name starterkit_theme # label #@starterkit:label Starterkit theme # machine_class_name #@starterkit:machine_class_name StarterkitTheme # label_class_name #@starterkit:label_class_name StarterkitTheme FIXTURE; Loading Loading @@ -523,13 +627,13 @@ class StarterkitThemePreRender implements TrustedCallbackInterface { 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 #@starterkit:machine_name test_custom_theme # label #@starterkit:label Test custom starterkit theme # machine_class_name #@starterkit:machine_class_name TestCustomTheme # label_class_name #@starterkit:label_class_name TestCustomTheme EDITED, file_get_contents($theme_path_absolute . '/edit_fixture.txt')); Loading Loading
core/lib/Drupal/Core/Command/GenerateTheme.php +35 −2 Original line number Diff line number Diff line Loading @@ -148,22 +148,55 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'old' => self::namePatterns($starterkit->getName(), $starterkit->info['name']), 'new' => self::namePatterns($destination_theme, $theme_label), ]; // Generate unique placeholders for each pattern key in $patterns['old'] so // we can perform a two-pass replacement without collisions. This avoids // cases where a replacement introduces text that matches another pattern // (or where short labels like "Simple" overlap with class-name fragments), // which would otherwise cause accidental double replacements. $placeholders = []; foreach (array_keys($patterns['old']) as $key) { $placeholders[$key] = '__' . strtoupper($key) . '__'; } $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); // Step 1: Replace old patterns with placeholders in content. $contents = str_replace($patterns['old'], $placeholders, $contents); // Step 2: Replace placeholders with new patterns in content. $contents = str_replace($placeholders, $patterns['new'], $contents); // Normalize comment marker driven replacements. When the source theme // uses very short labels (for example "Simple") the generic // replacements above can map the text to the wrong placeholder because // both the label and class name fragments share the same literal value. // Unique, explicit marker lines make the intent unambiguous, so update // those lines after the generic pass. $markerReplacements = [ '#@starterkit:machine_name' => $patterns['new']['machine_name'], '#@starterkit:label' => $patterns['new']['label'], '#@starterkit:machine_class_name' => $patterns['new']['machine_name_pascal'], '#@starterkit:label_class_name' => $patterns['new']['machine_name_pascal'], ]; foreach ($markerReplacements as $marker => $replacement) { $pattern = '/(^\s*' . preg_quote($marker, '/') . '\s*\R)([^\r\n]*)/m'; $contents = preg_replace($pattern, '$1' . $replacement, $contents); } file_put_contents($file->getRealPath(), $contents); } // Step 3: Repeat for file renaming. $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); $filename = str_replace($patterns['old'], $placeholders, $filename); $filename = str_replace($placeholders, $patterns['new'], $filename); $filepath_segments[] = $filename; $filesystem->rename($file->getRealPath(), implode('/', $filepath_segments)); } Loading
core/tests/Drupal/BuildTests/Command/GenerateThemeTest.php +112 −8 Original line number Diff line number Diff line Loading @@ -45,6 +45,110 @@ protected function setUp(): void { chdir($this->getWorkingPath()); } /** * Tests generating theme from a simple named Starterkit enabled theme. */ public function testSimpleStarterkitTheme(): void { $starterkit_theme_path_relative = 'themes/simple'; $starterkit_theme_path_absolute = $this->getWorkspaceDirectory() . '/' . $starterkit_theme_path_relative; mkdir($starterkit_theme_path_absolute); file_put_contents($starterkit_theme_path_absolute . '/simple.info.yml', Yaml::encode([ 'name' => 'Simple', 'type' => 'theme', 'base theme' => FALSE, 'core_version_requirement' => '*', ])); file_put_contents($starterkit_theme_path_absolute . '/simple.starterkit.yml', Yaml::encode([ 'ignore' => ['/simple.starterkit.yml'], 'no_edit' => [], 'no_rename' => [], 'info' => [ 'version' => '1.0.0', ], ])); mkdir($starterkit_theme_path_absolute . '/src'); file_put_contents($starterkit_theme_path_absolute . '/src/SimpleUtility.php', <<<PHP <?php namespace Drupal\simple; final class SimpleUtility { public const MACHINE_NAME = 'simple'; public const MACHINE_CLASS_NAME = 'Simple'; } PHP); file_put_contents($starterkit_theme_path_absolute . '/simple.theme', <<<PHP <?php /** * @file * Drupal theme functions for Simple. */ function simple_preprocess_html(array &\$variables): void { \$variables['#attached']['drupalSettings']['simple'] = 'simple'; } PHP); $fixture = <<<FIXTURE #@starterkit:machine_name simple #@starterkit:label A Simple Theme #@starterkit:machine_class_name Simple #@starterkit:label_class_name Simple FIXTURE; file_put_contents($starterkit_theme_path_absolute . '/README.md', $fixture); $this->assertFileExists($starterkit_theme_path_absolute . '/simple.info.yml'); $this->assertThemeExists($starterkit_theme_path_relative); $tester = $this->runCommand([ 'machine-name' => 'simple_theme', '--name' => 'My Simple Theme', '--description' => 'Custom theme generated from a Simple Starterkit theme', '--starterkit' => 'simple', ]); $tester->assertCommandIsSuccessful(); $theme_path_relative = 'themes/simple_theme'; $this->assertThemeExists($theme_path_relative); $info = $this->assertThemeExists($theme_path_relative); self::assertEquals('My Simple Theme', $info['name']); $readme_file = $this->getWorkspaceDirectory() . "/$theme_path_relative/README.md"; $this->assertFileExists($readme_file); $fixture = <<<FIXTURE #@starterkit:machine_name simple_theme #@starterkit:label My Simple Theme #@starterkit:machine_class_name SimpleTheme #@starterkit:label_class_name SimpleTheme FIXTURE; $this->assertSame($fixture, file_get_contents($readme_file)); // The .theme file should be renamed and contain updated machine and label // values. $dot_theme_path = $this->getWorkspaceDirectory() . "/$theme_path_relative/simple_theme.theme"; $this->assertFileExists($dot_theme_path); $dot_theme_contents = file_get_contents($dot_theme_path); self::assertStringContainsString("\$variables['#attached']['drupalSettings']['simple_theme']", $dot_theme_contents); // Ensure content replacements respect the namespace and class fragments. $utility_file = $this->getWorkspaceDirectory() . "/$theme_path_relative/src/SimpleThemeUtility.php"; $this->assertFileExists($utility_file); $utility_contents = file_get_contents($utility_file); self::assertStringContainsString('namespace Drupal\\simple_theme;', $utility_contents); self::assertStringContainsString('class SimpleThemeUtility', $utility_contents); self::assertStringContainsString("public const MACHINE_NAME = 'simple_theme'", $utility_contents); self::assertStringContainsString("public const MACHINE_CLASS_NAME = 'SimpleTheme'", $utility_contents); } /** * Generates PHP process to generate a theme from core's starterkit theme. * Loading Loading @@ -478,13 +582,13 @@ public function testNoEdit(): void { ], ]); $fixture = <<<FIXTURE # machine_name #@starterkit:machine_name starterkit_theme # label #@starterkit:label Starterkit theme # machine_class_name #@starterkit:machine_class_name StarterkitTheme # label_class_name #@starterkit:label_class_name StarterkitTheme FIXTURE; Loading Loading @@ -523,13 +627,13 @@ class StarterkitThemePreRender implements TrustedCallbackInterface { 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 #@starterkit:machine_name test_custom_theme # label #@starterkit:label Test custom starterkit theme # machine_class_name #@starterkit:machine_class_name TestCustomTheme # label_class_name #@starterkit:label_class_name TestCustomTheme EDITED, file_get_contents($theme_path_absolute . '/edit_fixture.txt')); Loading