diff --git a/README.md b/README.md index de978d6..5ce6030 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,10 @@ codebase now uses PHP 8.4-only syntax where it improves readability. `drush emulsify [theme_name]` +`drush emulsify_tools:repair-favicon-config` + +`drush emulsify_tools:repair-favicon-config [theme_machine_name]` + ### Twig Namespaces Emulsify themes can register Symfony-style Twig namespaces in their `.info.yml` @@ -140,6 +144,47 @@ This adds the ability to do a `switch/case` function from within Twig templates. Note that the `switch`, `endswitch`, and `case` tags are required and the `default` is optional. +## Updating 6.x to 7.x + +Upgrading from Emulsify 6.x to 7.x introduces a new generated favicon workflow. +Instead of relying only on legacy theme-level favicon settings, Emulsify 7.x +stores a portable SVG source and generated package metadata in theme settings so +favicon packages can be regenerated consistently across environments. + +### What changes + +- Active theme settings gain new favicon keys such as `favicon_source_svg`, + `favicon_source_filename`, platform-specific color and padding settings, and + generated package metadata fields like `favicon_package_hash`, + `favicon_package_path`, and `favicon_package_generated_at`. +- Installed Emulsify-based themes can be migrated in place by running Drupal + database updates. This module provides a post update that backfills missing + favicon keys in active `.settings` config and, when possible, stores a + sanitized portable SVG source from the existing managed favicon file. +- Older generated child themes may still be missing the source files that define + those settings for fresh installs and future config exports. + +### Child Theme Source Repair + +Run the repair command in the Drupal site root to update older Emulsify-based +child theme codebases: + +`drush emulsify_tools:repair-favicon-config` + +To target a single child theme: + +`drush emulsify_tools:repair-favicon-config my_child_theme` + +The command scans Emulsify-based child themes in the current codebase and +backfills missing favicon entries in: + +- `config/install/.settings.yml` +- `config/schema/.schema.yml` + +Existing values are preserved. Only missing or `NULL` favicon keys and schema +definitions are filled in. Review and commit those child theme source-file +changes after running the command. + ## Development --- diff --git a/emulsify_tools.post_update.php b/emulsify_tools.post_update.php new file mode 100644 index 0000000..6dc9182 --- /dev/null +++ b/emulsify_tools.post_update.php @@ -0,0 +1,22 @@ +backfill(); + + return (string) t( + 'Backfilled favicon settings for @updated of @affected installed Emulsify-based themes. This migrates only the active .settings config on the current site; older generated child themes still need their own default config and schema files updated in source so fresh installs and future exports include the new keys.', + [ + '@updated' => (string) $result['updated_count'], + '@affected' => (string) $result['affected_count'], + ], + ); +} diff --git a/emulsify_tools.services.yml b/emulsify_tools.services.yml index 13e3106..8f5aa55 100644 --- a/emulsify_tools.services.yml +++ b/emulsify_tools.services.yml @@ -29,6 +29,17 @@ services: emulsify_tools.subtheme_generator: class: Drupal\emulsify_tools\SubThemeGenerator arguments: ['@emulsify_tools.filesystem'] + Drupal\emulsify_tools\Favicon\FaviconSourceSanitizerInterface: + alias: emulsify_tools.favicon_source_sanitizer + emulsify_tools.favicon_source_sanitizer: + class: Drupal\emulsify_tools\Favicon\EmulsifyFaviconSourceSanitizer + autowire: true + Drupal\emulsify_tools\Favicon\ChildThemeFaviconConfigRepairer: + class: Drupal\emulsify_tools\Favicon\ChildThemeFaviconConfigRepairer + autowire: true + Drupal\emulsify_tools\Favicon\FaviconThemeSettingsBackfill: + class: Drupal\emulsify_tools\Favicon\FaviconThemeSettingsBackfill + autowire: true emulsify_tools.twig.switch: class: Drupal\emulsify_tools\SwitchExtension tags: diff --git a/src/Drush/Commands/SubThemeCommands.php b/src/Drush/Commands/SubThemeCommands.php index 4ebd1d5..bd12903 100644 --- a/src/Drush/Commands/SubThemeCommands.php +++ b/src/Drush/Commands/SubThemeCommands.php @@ -7,6 +7,7 @@ use Drupal\Component\Utility\UrlHelper; use Drupal\Core\Archiver\ArchiverManager; use Drupal\Core\Extension\ThemeExtensionList; +use Drupal\emulsify_tools\Favicon\ChildThemeFaviconConfigRepairer; use Drupal\emulsify_tools\SubThemeGenerator; use Drush\Attributes as CLI; use Drush\Commands\AutowireTrait; @@ -39,6 +40,7 @@ public function __construct( private readonly ArchiverManager $archiverManager, private readonly SubThemeGenerator $subThemeGenerator, private readonly Filesystem $filesystem, + private readonly ChildThemeFaviconConfigRepairer $childThemeFaviconConfigRepairer, ) { parent::__construct(); } @@ -69,6 +71,55 @@ public function generateSubTheme(string $name): CollectionBuilder { return $builder; } + /** + * Repairs child theme favicon install and schema files for Emulsify 7.x. + */ + #[CLI\Command(name: 'emulsify_tools:repair-favicon-config')] + #[CLI\Argument(name: 'theme', description: 'Optional Emulsify-based child theme machine name.')] + #[CLI\Usage(name: 'emulsify_tools:repair-favicon-config')] + #[CLI\Usage(name: 'emulsify_tools:repair-favicon-config my_child_theme')] + public function repairFaviconConfig(?string $theme = NULL): int { + try { + $result = $this->childThemeFaviconConfigRepairer->repair($theme); + } + catch (\InvalidArgumentException $exception) { + $this->logger()->error($exception->getMessage()); + return 1; + } + catch (\Throwable $exception) { + $this->logger()->error($exception->getMessage()); + return 1; + } + + foreach ($result['updated_themes'] as $themeName => $themeResult) { + $this->logger()->notice(sprintf( + 'Updated %s (%s): install=%s, schema=%s.', + $themeName, + $themeResult['path'], + $themeResult['install'], + $themeResult['schema'], + )); + } + + foreach ($result['errors'] as $themeName => $message) { + $this->logger()->error(sprintf('Unable to repair %s: %s', $themeName, $message)); + } + + if ($result['updated_count'] === 0 && $result['errors'] === []) { + $this->logger()->notice('No Emulsify child theme favicon source files needed repair.'); + } + + $this->logger()->notice(sprintf( + 'Inspected %d Emulsify-based child themes: %d updated, %d unchanged, %d errors.', + $result['inspected_count'], + $result['updated_count'], + $result['unchanged_count'], + count($result['errors']), + )); + + return $result['errors'] === [] ? 0 : 1; + } + /** * Convert label to machine name. * diff --git a/src/Favicon/ChildThemeFaviconConfigRepairer.php b/src/Favicon/ChildThemeFaviconConfigRepairer.php new file mode 100644 index 0000000..07e7bb6 --- /dev/null +++ b/src/Favicon/ChildThemeFaviconConfigRepairer.php @@ -0,0 +1,295 @@ +|null + */ + private ?array $installTemplateSettings = NULL; + + /** + * Cached schema mapping template. + * + * @var array|null + */ + private ?array $schemaTemplateDefinition = NULL; + + /** + * Creates the repairer. + */ + public function __construct( + #[Autowire(param: 'app.root')] + private readonly string $appRoot, + private readonly ThemeExtensionList $themeExtensionList, + private readonly Filesystem $filesystem, + ) {} + + /** + * Repairs favicon install and schema files for Emulsify child themes. + * + * @return array{ + * inspected_count: int, + * updated_count: int, + * unchanged_count: int, + * updated_themes: array, + * errors: array + * } + * A summary of the repair results. + */ + public function repair(?string $requestedThemeName = NULL): array { + $themes = $this->getEligibleThemes(); + if ($requestedThemeName !== NULL) { + if (!isset($themes[$requestedThemeName])) { + throw new \InvalidArgumentException(sprintf('Theme "%s" is not an Emulsify-based child theme in this codebase.', $requestedThemeName)); + } + + $themes = [$requestedThemeName => $themes[$requestedThemeName]]; + } + + $updatedThemes = []; + $errors = []; + + foreach ($themes as $themeName => $theme) { + try { + $result = $this->repairTheme($theme); + } + catch (\Throwable $throwable) { + $errors[$themeName] = $throwable->getMessage(); + continue; + } + + if ($result['install'] !== 'unchanged' || $result['schema'] !== 'unchanged') { + $updatedThemes[$themeName] = $result; + } + } + + return [ + 'inspected_count' => count($themes), + 'updated_count' => count($updatedThemes), + 'unchanged_count' => count($themes) - count($updatedThemes), + 'updated_themes' => $updatedThemes, + 'errors' => $errors, + ]; + } + + /** + * Repairs a single child theme's source files. + * + * @return array{path: string, install: string, schema: string} + * The file-level repair result. + */ + private function repairTheme(Extension $theme): array { + $themeName = $theme->getName(); + $themePath = $this->getAbsoluteThemePath($theme); + + $installPath = $themePath . '/config/install/' . $themeName . '.settings.yml'; + $schemaPath = $themePath . '/config/schema/' . $themeName . '.schema.yml'; + + [$installSettings, $installExisted] = $this->loadYamlFile($installPath); + if (!is_array($installSettings)) { + $installSettings = []; + } + $mergedInstallSettings = $this->mergeMissingRecursive($installSettings, $this->getInstallTemplateSettings()); + $installAction = $this->writeYamlFileIfChanged($installPath, $installSettings, $mergedInstallSettings, $installExisted); + + [$schemaDefinitions, $schemaExisted] = $this->loadYamlFile($schemaPath); + if (!is_array($schemaDefinitions)) { + $schemaDefinitions = []; + } + + $schemaKey = $themeName . '.settings'; + $existingSchemaDefinition = $schemaDefinitions[$schemaKey] ?? []; + if (!is_array($existingSchemaDefinition)) { + $existingSchemaDefinition = []; + } + + $templateSchemaDefinition = $this->getSchemaTemplateDefinition($theme); + $mergedSchemaDefinition = $this->mergeMissingRecursive($existingSchemaDefinition, $templateSchemaDefinition); + $mergedSchemaDefinitions = $schemaDefinitions; + $mergedSchemaDefinitions[$schemaKey] = $mergedSchemaDefinition; + $schemaAction = $this->writeYamlFileIfChanged($schemaPath, $schemaDefinitions, $mergedSchemaDefinitions, $schemaExisted); + + return [ + 'path' => $theme->getPath(), + 'install' => $installAction, + 'schema' => $schemaAction, + ]; + } + + /** + * Returns all Emulsify-based child themes in the current codebase. + * + * @return array + * Themes keyed by machine name. + */ + private function getEligibleThemes(): array { + $themes = []; + + foreach ($this->themeExtensionList->getList() as $themeName => $theme) { + if (!$theme instanceof Extension || $themeName === self::BASE_THEME) { + continue; + } + + if (!in_array(self::BASE_THEME, array_keys($theme->base_themes ?? []), TRUE)) { + continue; + } + + $themes[$themeName] = $theme; + } + + ksort($themes); + + return $themes; + } + + /** + * Returns the install settings template from the base theme. + * + * @return array + * The settings template. + */ + private function getInstallTemplateSettings(): array { + if ($this->installTemplateSettings !== NULL) { + return $this->installTemplateSettings; + } + + $templatePath = $this->resolveBaseThemeTemplatePath('config/install/emulsify.settings.yml'); + [$settings] = $this->loadYamlFile($templatePath, TRUE); + if (!is_array($settings)) { + throw new \RuntimeException(sprintf('The install template at "%s" is not a YAML mapping.', $templatePath)); + } + + return $this->installTemplateSettings = $settings; + } + + /** + * Returns the schema definition template for a child theme. + * + * @return array + * The schema definition. + */ + private function getSchemaTemplateDefinition(Extension $theme): array { + if ($this->schemaTemplateDefinition === NULL) { + $templatePath = $this->resolveBaseThemeTemplatePath('config/schema/emulsify.schema.yml'); + [$definitions] = $this->loadYamlFile($templatePath, TRUE); + if (!is_array($definitions)) { + throw new \RuntimeException(sprintf('The schema template at "%s" is not a YAML mapping.', $templatePath)); + } + + $definition = $definitions[self::BASE_THEME . '.settings'] ?? NULL; + if (!is_array($definition)) { + throw new \RuntimeException(sprintf('The schema template at "%s" is missing the "%s.settings" definition.', $templatePath, self::BASE_THEME)); + } + + $this->schemaTemplateDefinition = $definition; + } + + $definition = $this->schemaTemplateDefinition; + $definition['label'] = sprintf('%s settings', (string) ($theme->info['name'] ?? $theme->getName())); + + return $definition; + } + + /** + * Loads a YAML file if present. + * + * @return array{0: mixed, 1: bool} + * The decoded content and whether the file existed. + */ + private function loadYamlFile(string $path, bool $required = FALSE): array { + if (!is_file($path)) { + if ($required) { + throw new \RuntimeException(sprintf('Required template file "%s" was not found.', $path)); + } + + return [[], FALSE]; + } + + $contents = file_get_contents($path); + if ($contents === FALSE) { + throw new \RuntimeException(sprintf('Unable to read "%s".', $path)); + } + + $decoded = Yaml::decode($contents); + + return [$decoded ?? [], TRUE]; + } + + /** + * Writes a YAML file only when the merged content changes. + */ + private function writeYamlFileIfChanged(string $path, array $existing, array $merged, bool $fileExisted): string { + if ($merged === $existing) { + return 'unchanged'; + } + + $this->filesystem->mkdir(dirname($path)); + $this->filesystem->dumpFile($path, Yaml::encode($merged) . "\n"); + + return $fileExisted ? 'updated' : 'created'; + } + + /** + * Fills missing array keys recursively while preserving existing values. + */ + private function mergeMissingRecursive(mixed $existing, mixed $defaults): mixed { + if (!is_array($defaults)) { + return $existing ?? $defaults; + } + + $merged = is_array($existing) ? $existing : []; + + foreach ($defaults as $key => $value) { + if (!array_key_exists($key, $merged) || $merged[$key] === NULL) { + $merged[$key] = $value; + continue; + } + + if (is_array($value) && is_array($merged[$key])) { + $merged[$key] = $this->mergeMissingRecursive($merged[$key], $value); + } + } + + return $merged; + } + + /** + * Resolves a base-theme template path. + */ + private function resolveBaseThemeTemplatePath(string $relativePath): string { + $baseThemePath = $this->themeExtensionList->getPath(self::BASE_THEME); + if ($baseThemePath === '') { + throw new \RuntimeException('The Emulsify base theme could not be found.'); + } + + return $this->appRoot . '/' . trim($baseThemePath, '/\\') . '/' . ltrim($relativePath, '/\\'); + } + + /** + * Returns the absolute filesystem path for a theme extension. + */ + private function getAbsoluteThemePath(Extension $theme): string { + return $this->appRoot . '/' . trim($theme->getPath(), '/\\'); + } + +} diff --git a/src/Favicon/EmulsifyFaviconSourceSanitizer.php b/src/Favicon/EmulsifyFaviconSourceSanitizer.php new file mode 100644 index 0000000..729c5d6 --- /dev/null +++ b/src/Favicon/EmulsifyFaviconSourceSanitizer.php @@ -0,0 +1,47 @@ +fileSystem, + $this->fileUrlGenerator, + $this->configFactory, + $this->cacheTagsInvalidator, + $this->time, + ); + $analysis = $generator->validateSourceFile($file, FALSE); + + return (string) ($analysis['sanitized_svg'] ?? ''); + } + +} diff --git a/src/Favicon/FaviconSourceSanitizerInterface.php b/src/Favicon/FaviconSourceSanitizerInterface.php new file mode 100644 index 0000000..2bec6b8 --- /dev/null +++ b/src/Favicon/FaviconSourceSanitizerInterface.php @@ -0,0 +1,22 @@ + 0, + 'updated_count' => 0, + 'updated_themes' => [], + ]; + } + + $siteName = $this->getSiteName(); + $affectedThemeNames = $this->getAffectedThemeNames(); + $updatedThemes = []; + + foreach ($affectedThemeNames as $themeName) { + $config = $this->configFactory->getEditable($themeName . '.settings'); + $storedSettings = $this->getStoredSettings($config); + $normalizedSettings = FaviconSettings::normalize($storedSettings, $siteName); + + $changed = $this->backfillMissingDefaults($config, $storedSettings, $normalizedSettings); + $changed = $this->backfillPortableSource($config, $storedSettings) || $changed; + + if ($changed) { + $config->save(TRUE); + $updatedThemes[] = $themeName; + } + } + + return [ + 'affected_count' => count($affectedThemeNames), + 'updated_count' => count($updatedThemes), + 'updated_themes' => $updatedThemes, + ]; + } + + /** + * Returns the installed themes that should receive the migration. + * + * @return string[] + * Theme machine names. + */ + private function getAffectedThemeNames(): array { + $themeNames = []; + + foreach ($this->themeHandler->listInfo() as $themeName => $theme) { + if ($themeName === self::BASE_THEME) { + $themeNames[] = $themeName; + continue; + } + + $baseThemes = []; + if (is_object($theme) && isset($theme->base_themes) && is_array($theme->base_themes)) { + $baseThemes = array_keys($theme->base_themes); + } + + if (in_array(self::BASE_THEME, $baseThemes, TRUE)) { + $themeNames[] = $themeName; + } + } + + sort($themeNames); + + return $themeNames; + } + + /** + * Returns editable config raw data as an array. + */ + private function getStoredSettings(Config $config): array { + $settings = $config->getRawData(); + + return is_array($settings) ? $settings : []; + } + + /** + * Fills any missing or NULL favicon settings using current defaults. + */ + private function backfillMissingDefaults(Config $config, array $storedSettings, array $normalizedSettings): bool { + $changed = FALSE; + + foreach (array_keys(FaviconSettings::DEFAULTS) as $key) { + if (array_key_exists($key, $storedSettings) && $storedSettings[$key] !== NULL) { + continue; + } + + $config->set($key, $normalizedSettings[$key] ?? FaviconSettings::DEFAULTS[$key]); + $changed = TRUE; + } + + return $changed; + } + + /** + * Backfills the portable SVG source from an existing managed file. + */ + private function backfillPortableSource(Config $config, array $storedSettings): bool { + if (empty($storedSettings['favicon_package_enabled'])) { + return FALSE; + } + + if (trim((string) ($storedSettings['favicon_source_svg'] ?? '')) !== '') { + return FALSE; + } + + $sourceFileId = FaviconSettings::getSourceFileId($storedSettings); + if ($sourceFileId === NULL) { + return FALSE; + } + + $sourceFile = $this->loadSourceFile($sourceFileId); + if (!$sourceFile instanceof FileInterface) { + return FALSE; + } + + try { + $sourceSvg = $this->sourceSanitizer->sanitizeSourceFile($sourceFile); + } + catch (\Throwable) { + return FALSE; + } + + if ($sourceSvg === '') { + return FALSE; + } + + $config->set('favicon_source_svg', $sourceSvg); + $config->set('favicon_source_filename', $sourceFile->getFilename()); + + return TRUE; + } + + /** + * Loads a source file entity from its file ID. + */ + private function loadSourceFile(int $fileId): ?FileInterface { + $storage = $this->getFileStorage(); + $file = $storage->load($fileId); + + return $file instanceof FileInterface ? $file : NULL; + } + + /** + * Returns file storage. + */ + private function getFileStorage(): EntityStorageInterface { + return $this->entityTypeManager->getStorage('file'); + } + + /** + * Returns the current site name. + */ + private function getSiteName(): string { + return (string) $this->configFactory->get('system.site')->get('name'); + } + +}