diff --git a/.github/workflows/addtoprojects.yml b/.github/workflows/addtoprojects.yml index 7bebb51..a2b965b 100644 --- a/.github/workflows/addtoprojects.yml +++ b/.github/workflows/addtoprojects.yml @@ -18,4 +18,4 @@ jobs: # You can target a repository in a different organization # to the issue project-url: https://github.com/orgs/emulsify-ds/projects/6 - github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} + github-token: ${{ secrets.GH_TOKEN }} \ No newline at end of file diff --git a/config/schema/emulsify_tools.schema.yml b/config/schema/emulsify_tools.schema.yml new file mode 100644 index 0000000..c3434f5 --- /dev/null +++ b/config/schema/emulsify_tools.schema.yml @@ -0,0 +1,29 @@ +emulsify_tools.favicon_package.*: + type: config_entity + label: 'Favicon Package config' + mapping: + id: + type: string + label: 'ID' + label: + type: label + label: 'Label' + uuid: + type: string + tags: + type: sequence + label: 'Tags' + archive: + type: sequence + label: 'Archive' + +emulsify_tools.settings: + type: config_object + label: 'Emulsify Tools settings' + mapping: + themes: + type: sequence + label: 'Theme Favicon Mappings' + sequence: + type: string + label: 'Favicon Package ID' diff --git a/emulsify_tools.info.yml b/emulsify_tools.info.yml index 071a839..bbceac1 100644 --- a/emulsify_tools.info.yml +++ b/emulsify_tools.info.yml @@ -4,6 +4,9 @@ description: Toolset of useful Twig extensions and a subtheme generation command core_version_requirement: ^10 || ^11 package: Emulsify +dependencies: + - drupal:file + # Information added by Drupal.org packaging script on 2023-02-16 version: '1.0.2' project: 'emulsify_tools' diff --git a/emulsify_tools.module b/emulsify_tools.module new file mode 100644 index 0000000..2200f57 --- /dev/null +++ b/emulsify_tools.module @@ -0,0 +1,132 @@ +getBuildInfo(); + $theme = $build_info['args'][0] ?? ''; + if (!empty($theme)) { + return; + } + + $form['favicon_packages_wrapper'] = [ + '#type' => 'details', + '#title' => t('Favicon Packages'), + '#open' => TRUE, + '#weight' => -10, + ]; + + $add_url = Url::fromRoute('entity.favicon_package.add_form', [], ['query' => ['destination' => '/admin/appearance/settings']]); + $form['favicon_packages_wrapper']['add_link'] = [ + '#type' => 'link', + '#title' => t('Add new Favicon Package'), + '#url' => $add_url, + '#attributes' => ['class' => ['button', 'button--primary', 'button--small']], + ]; + + // List existing packages. + $list_builder = \Drupal::entityTypeManager()->getListBuilder('favicon_package'); + $form['favicon_packages_wrapper']['package_list'] = $list_builder->render(); + + // Mapping theme to packages. + $config = \Drupal::config('emulsify_tools.settings'); + $config_themes = $config->get('themes') ?: []; + + $form['favicon_packages_wrapper']['theme_mappings'] = [ + '#type' => 'details', + '#title' => t('Theme Favicon Mappings'), + '#description' => t('Assign a favicon package to each theme.'), + '#open' => TRUE, + '#tree' => TRUE, + ]; + + $packages = \Drupal::entityTypeManager()->getStorage('favicon_package')->loadMultiple(); + $favicon_options = [0 => t('- Use Drupal Default -')]; + foreach ($packages as $package) { + $favicon_options[$package->id()] = $package->label(); + } + + $themes = \Drupal::service('theme_handler')->listInfo(); + uasort($themes, 'Drupal\Core\Extension\ExtensionList::sortByName'); + + foreach ($themes as $id => $theme) { + if (!empty($theme->info['hidden'])) { + continue; + } + if (!empty($theme->status)) { + $form['favicon_packages_wrapper']['theme_mappings'][$id] = [ + '#type' => 'select', + '#title' => t('@name Favicon', ['@name' => $theme->info['name']]), + '#options' => $favicon_options, + '#default_value' => isset($config_themes[$id]) ? $config_themes[$id] : 0, + ]; + } + } + + $form['#submit'][] = 'emulsify_tools_system_theme_settings_submit'; +} + +/** + * Submit handler for system_theme_settings. + */ +function emulsify_tools_system_theme_settings_submit($form, FormStateInterface $form_state) { + $mappings = $form_state->getValue('theme_mappings'); + if ($mappings) { + \Drupal::configFactory()->getEditable('emulsify_tools.settings') + ->set('themes', array_filter($mappings)) + ->save(); + } +} + +/** + * Implements hook_page_attachments_alter(). + */ +function emulsify_tools_page_attachments_alter(array &$attachments) { + $theme = \Drupal::theme()->getActiveTheme()->getName(); + /** @var \Drupal\emulsify_tools\FaviconManagerInterface $faviconManager */ + $faviconManager = \Drupal::service('emulsify_tools.favicon.manager'); + + if ($tags = $faviconManager->getTags($theme)) { + // Remove default favicon from html_head_link. + if (!empty($attachments['#attached']['html_head_link'])) { + foreach ($attachments['#attached']['html_head_link'] as $i => $item) { + if (!empty($item) && is_array($item)) { + foreach ($item as $ii => $iitem) { + if (isset($iitem['rel']) && in_array($iitem['rel'], ['shortcut icon', 'icon'])) { + unset($attachments['#attached']['html_head_link'][$i][$ii]); + } + } + if (empty($attachments['#attached']['html_head_link'][$i])) { + unset($attachments['#attached']['html_head_link'][$i]); + } + } + } + if (empty($attachments['#attached']['html_head_link'])) { + unset($attachments['#attached']['html_head_link']); + } + } + // Attach favicon tags. + $attachments['#attached']['html_head'][] = [ + [ + '#type' => 'markup', + '#markup' => $tags, + '#allowed_tags' => ['link', 'meta'], + '#cache' => [ + 'tags' => $faviconManager->getCacheTags(), + ], + ], + 'emulsify_tools_favicon', + ]; + } +} diff --git a/emulsify_tools.routing.yml b/emulsify_tools.routing.yml new file mode 100644 index 0000000..13d9ad9 --- /dev/null +++ b/emulsify_tools.routing.yml @@ -0,0 +1,31 @@ +entity.favicon_package.collection: + path: '/admin/structure/favicon-package' + defaults: + _entity_list: 'favicon_package' + _title: 'Favicon Packages' + requirements: + _permission: 'administer site configuration' + +entity.favicon_package.add_form: + path: '/admin/structure/favicon-package/add' + defaults: + _entity_form: 'favicon_package.add' + _title: 'Add Favicon Package' + requirements: + _permission: 'administer site configuration' + +entity.favicon_package.edit_form: + path: '/admin/structure/favicon-package/{favicon_package}/edit' + defaults: + _entity_form: 'favicon_package.edit' + _title: 'Edit Favicon Package' + requirements: + _permission: 'administer site configuration' + +entity.favicon_package.delete_form: + path: '/admin/structure/favicon-package/{favicon_package}/delete' + defaults: + _entity_form: 'favicon_package.delete' + _title: 'Delete Favicon Package' + requirements: + _permission: 'administer site configuration' diff --git a/emulsify_tools.services.yml b/emulsify_tools.services.yml index 238d8dc..39e07eb 100644 --- a/emulsify_tools.services.yml +++ b/emulsify_tools.services.yml @@ -16,3 +16,6 @@ services: - { name: drush.command } emulsify_tools.subtheme_generator: class: Drupal\emulsify_tools\SubThemeGenerator + emulsify_tools.favicon.manager: + class: Drupal\emulsify_tools\FaviconManager + arguments: ['@entity_type.manager', '@config.factory', '@cache.data'] diff --git a/src/BemTwigExtension.php b/src/BemTwigExtension.php index 5b2f856..4d4d701 100644 --- a/src/BemTwigExtension.php +++ b/src/BemTwigExtension.php @@ -5,7 +5,6 @@ use Drupal\Core\Template\Attribute; use Twig\Extension\AbstractExtension; use Twig\TwigFunction; -use Drupal\Component\Utility\Html; /** * Class DefaultService. @@ -130,10 +129,6 @@ public function bem($context, $base_class, $modifiers = [], $blockname = '', $ex } // Add class attribute. if (!empty($classes)) { - // Escape the css classes added to prevent security issues. - $classes = array_map(function($css_class) { - return Html::cleanCssIdentifier($css_class); - }, $classes); $attributes->setAttribute('class', $classes); } return $attributes; diff --git a/src/Entity/FaviconPackage.php b/src/Entity/FaviconPackage.php new file mode 100644 index 0000000..7030dff --- /dev/null +++ b/src/Entity/FaviconPackage.php @@ -0,0 +1,321 @@ + $tag) { + $tags[$pos] = trim($tag); + } + $this->set('tags', $tags); + } + + /** + * {@inheritDoc} + */ + public function getTagsAsString() { + $tags = $this->get('tags'); + return $tags ? implode(PHP_EOL, $tags) : ''; + } + + /** + * Get the tags. + */ + public function getTags() { + return $this->get('tags'); + } + + /** + * Get the manifest. + */ + public function getManifest() { + if (empty($this->manifest)) { + $this->manifest = []; + $path = $this->getDirectory() . '/manifest.json'; + if (file_exists($path)) { + $data = file_get_contents($path); + $this->manifest = Json::decode($data); + } + } + return $this->manifest; + } + + /** + * Get the largest manifest image. + */ + public function getManifestLargeImage() { + $image = ''; + if ($manifest = $this->getManifest()) { + $size = 0; + foreach ($manifest['icons'] as $icon) { + $icon_size = explode('x', $icon['sizes']); + if ($icon_size[0] > $size) { + $image = $this->getDirectory() . $icon['src']; + } + } + } + else { + return $this->getDirectory() . '/apple-touch-icon.png'; + } + return $image; + } + + /** + * {@inheritDoc} + */ + public function setArchive($zip_path) { + $data = strtr(base64_encode(addslashes(gzcompress(serialize(file_get_contents($zip_path)), 9))), '+/=', '-_,'); + $parts = str_split($data, 200000); + $this->set('archive', $parts); + } + + /** + * Get the archive from base64 encoded string. + */ + public function getArchive() { + $data = implode('', $this->get('archive')); + return unserialize(gzuncompress(stripslashes(base64_decode(strtr($data, '-_,', '+/='))))); + } + + /** + * Get a favicon image. + */ + public function getThumbnail($image_name = 'favicon-96x96.png') { + return $this->getDirectory() . '/' . $image_name; + } + + /** + * Return the location where Favicon Packages exist. + * + * @return string + * The directory path. + */ + public function getDirectory() { + return $this->directory . '/' . $this->id(); + } + + /** + * {@inheritdoc} + */ + public function preSave(EntityStorageInterface $storage) { + parent::preSave($storage); + + if (!$this->isNew()) { + $original = $storage->loadUnchanged($this->getOriginalId()); + } + + if (is_string($this->get('tags'))) { + $this->setTagsAsString($this->get('tags')); + } + + if (!$this->get('archive')) { + throw new EntityMalformedException('Favicon package archive is required.'); + } + if ($this->isNew() || (isset($original) && $original->get('archive') !== $this->get('archive'))) { + $this->archiveDecode(); + } + } + + /** + * {@inheritdoc} + */ + public static function preDelete(EntityStorageInterface $storage, array $entities) { + parent::preDelete($storage, $entities); + /** @var \Drupal\Core\File\FileSystemInterface $file_system */ + $file_system = \Drupal::service('file_system'); + foreach ($entities as $entity) { + /** @var \Drupal\emulsify_tools\Entity\FaviconPackageInterface $entity */ + $file_system->deleteRecursive($entity->getDirectory()); + // Clean up empty directory. Will fail silently if it is not empty. + @rmdir($entity->getDirectory()); + } + } + + /** + * Take base64 encoded archive and save it to a temporary file for extraction. + */ + protected function archiveDecode() { + $data = $this->getArchive(); + $zip_path = 'temporary://' . $this->id() . '.zip'; + file_put_contents($zip_path, $data); + $this->archiveExtract($zip_path); + } + + /** + * Properly extract and store an archive. + * + * @param string $zip_path + * The absolute path to the zip file. + */ + public function archiveExtract($zip_path) { + /** @var \Drupal\Core\File\FileSystemInterface $file_system */ + $file_system = \Drupal::service('file_system'); + /** @var \Drupal\Core\Archiver\ArchiverManager $archiver_manager */ + $archiver_manager = \Drupal::service('plugin.manager.archiver'); + $archiver = $archiver_manager->getInstance(['filepath' => $zip_path]); + if (!$archiver) { + throw new \Exception(t('Cannot extract %file, not a valid archive.', ['%file' => $zip_path])); + } + + $directory = $this->getDirectory(); + $file_system->deleteRecursive($directory); + $file_system->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS); + $archiver->extract($directory); + + \Drupal::messenger()->addMessage(t('Favicon package has been successfully %op.', ['%op' => ($this->isNew() ? t('added') : t('updated'))])); + } + + /** + * Get valid tags as strings. + */ + public function getValidTagsAsString() { + return implode(PHP_EOL, $this->getValidTags()) . PHP_EOL; + } + + /** + * Get valid tags. + */ + public function getValidTags() { + $base_path = base_path(); + $html = $this->getTagsAsString(); + $found = []; + + if (empty($html)) { + return $found; + } + + $dom = new \DOMDocument(); + @$dom->loadHTML('' . $html); + + $base_path_normalized = preg_replace('/' . preg_quote($base_path, '/') . '$/', '/', DRUPAL_ROOT); + + // Find all the links. + $tags = $dom->getElementsByTagName('link'); + foreach ($tags as $tag) { + $href = $tag->getAttribute('href'); + if ($href) { + $file_path = $this->normalizePath($href); + $tag->setAttribute('href', $file_path); + + if (file_exists($base_path_normalized . $file_path) && is_readable($base_path_normalized . $file_path)) { + $found[] = $dom->saveXML($tag); + } + } + } + + // Find meta tags. + $tags = $dom->getElementsByTagName('meta'); + foreach ($tags as $tag) { + $name = $tag->getAttribute('name'); + + if ($name === 'msapplication-TileImage') { + $content = $tag->getAttribute('content'); + if ($content) { + $file_path = $this->normalizePath($content); + $tag->setAttribute('content', $file_path); + + if (file_exists($base_path_normalized . $file_path) && is_readable($base_path_normalized . $file_path)) { + $found[] = $dom->saveXML($tag); + } + } + } + else { + $found[] = $dom->saveXML($tag); + } + } + return $found; + } + + /** + * Normalize path. + * + * @return string + * The normalized path. + */ + protected function normalizePath($file_path) { + /** @var \Drupal\Core\File\FileUrlGeneratorInterface $url_generator */ + $url_generator = \Drupal::service('file_url_generator'); + // Extract filename if it starts with a slash or is just a relative path. + $filename = ltrim($file_path, '/'); + return $url_generator->generateString($this->getDirectory() . '/' . $filename); + } + +} diff --git a/src/Entity/FaviconPackageInterface.php b/src/Entity/FaviconPackageInterface.php new file mode 100644 index 0000000..5dbe780 --- /dev/null +++ b/src/Entity/FaviconPackageInterface.php @@ -0,0 +1,44 @@ +entityTypeManager = $entity_type_manager; + $this->config = $config_factory->get('emulsify_tools.settings'); + $this->cache = $cache; + } + + /** + * {@inheritdoc} + */ + public function getTags($theme_id) { + $tags = NULL; + $enabled = $this->config->get('themes'); + if (!empty($enabled[$theme_id])) { + $cid = $this->cid . '.tags.' . $theme_id; + if ($cache = $this->cache->get($cid)) { + $tags = $cache->data; + } + else { + if ($favicon = $this->loadFavicon($theme_id)) { + $tags = $favicon->getValidTagsAsString(); + } + $this->cache->set($cid, $tags, Cache::PERMANENT, $this->cacheTags); + } + } + return $tags; + } + + /** + * {@inheritdoc} + */ + public function loadFavicon($theme_id) { + $favicon = NULL; + $enabled = $this->config->get('themes'); + if (!empty($enabled[$theme_id])) { + $favicon = $this->entityTypeManager->getStorage('favicon_package')->load($enabled[$theme_id]); + } + return $favicon; + } + + /** + * {@inheritdoc} + */ + public function getCacheTags() { + return $this->cacheTags; + } + +} diff --git a/src/FaviconManagerInterface.php b/src/FaviconManagerInterface.php new file mode 100644 index 0000000..962ba2a --- /dev/null +++ b/src/FaviconManagerInterface.php @@ -0,0 +1,37 @@ +get('entity_type.manager')->getStorage($entity_type->id()), + $container->get('theme_handler') + ); + } + + /** + * Constructs a new EntityListBuilder object. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * The entity type definition. + * @param \Drupal\Core\Entity\EntityStorageInterface $storage + * The entity storage class. + * @param \Drupal\Core\Extension\ThemeHandlerInterface $theme_handler + * The theme handler. + */ + public function __construct(EntityTypeInterface $entity_type, EntityStorageInterface $storage, ThemeHandlerInterface $theme_handler) { + $this->entityTypeId = $entity_type->id(); + $this->storage = $storage; + $this->entityType = $entity_type; + $this->themeHandler = $theme_handler; + } + + /** + * {@inheritdoc} + */ + public function buildHeader() { + $header['image'] = $this->t('Preview Image'); + $header['label'] = $this->t('Name'); + $header['id'] = $this->t('ID'); + return $header + parent::buildHeader(); + } + + /** + * {@inheritdoc} + */ + public function buildRow(EntityInterface $entity) { + /** @var \Drupal\emulsify_tools\Entity\FaviconPackageInterface $entity */ + $row['image'] = [ + 'data' => [ + '#theme' => 'image', + '#uri' => $entity->getThumbnail(), + '#alt' => $entity->label(), + '#width' => 32, + '#height' => 32, + ], + ]; + $row['label'] = $entity->label(); + $row['id'] = $entity->id(); + return $row + parent::buildRow($entity); + } + +} diff --git a/src/Form/FaviconPackageDeleteForm.php b/src/Form/FaviconPackageDeleteForm.php new file mode 100644 index 0000000..9220767 --- /dev/null +++ b/src/Form/FaviconPackageDeleteForm.php @@ -0,0 +1,52 @@ +t('Are you sure you want to delete %name?', ['%name' => $this->entity->label()]); + } + + /** + * {@inheritdoc} + */ + public function getCancelUrl() { + return new Url('entity.favicon_package.collection'); + } + + /** + * {@inheritdoc} + */ + public function getConfirmText() { + return $this->t('Delete'); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $this->entity->delete(); + + \Drupal::messenger()->addMessage( + $this->t('Favicon Package %label has been deleted.', + [ + '%label' => $this->entity->label(), + ] + ) + ); + + $form_state->setRedirectUrl($this->getCancelUrl()); + } + +} diff --git a/src/Form/FaviconPackageForm.php b/src/Form/FaviconPackageForm.php new file mode 100644 index 0000000..0647acc --- /dev/null +++ b/src/Form/FaviconPackageForm.php @@ -0,0 +1,123 @@ +entity; + $form['label'] = [ + '#type' => 'textfield', + '#title' => $this->t('Label'), + '#maxlength' => 255, + '#default_value' => $entity->label(), + '#description' => $this->t("Label for the Favicon Package."), + '#required' => TRUE, + ]; + + $form['id'] = [ + '#type' => 'machine_name', + '#default_value' => $entity->id(), + '#machine_name' => [ + 'exists' => '\Drupal\emulsify_tools\Entity\FaviconPackage::load', + 'replace_pattern' => '[^a-z0-9-]+', + 'replace' => '-', + ], + '#disabled' => !$entity->isNew(), + ]; + + $form['tags'] = [ + '#type' => 'textarea', + '#title' => $this->t('Tags'), + '#default_value' => $entity->getTagsAsString(), + '#description' => $this->t('Paste the code provided by @url. Make sure each link is on a separate line. It is fine to paste links with paths like "/apple-touch-icon-57x57.png" as these will be converted to the correct paths automatically.', ['@url' => 'http://realfavicongenerator.net/']), + '#required' => TRUE, + ]; + + $validators = [ + 'file_validate_extensions' => ['zip'], + 'file_validate_size' => [Environment::getUploadMaxSize()], + ]; + $form['file'] = [ + '#type' => 'file', + '#title' => $this->t('Upload a zip file from realfavicongenerator.net to install'), + '#description' => [ + '#theme' => 'file_upload_help', + '#description' => $this->t('For example: %filename from your local computer. This only needs to be done once.', ['%filename' => 'favicons.zip']), + '#upload_validators' => $validators, + ], + '#size' => 50, + '#upload_validators' => $validators, + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state) { + parent::validateForm($form, $form_state); + $this->file = file_save_upload('file', $form['file']['#upload_validators'], FALSE, 0); + // Ensure we have the file uploaded. + if (!$this->file && $this->entity->isNew()) { + $form_state->setErrorByName('file', $this->t('File to import not found.')); + } + } + + /** + * {@inheritdoc} + */ + public function save(array $form, FormStateInterface $form_state) { + /** @var \Drupal\emulsify_tools\Entity\FaviconPackageInterface $entity */ + $entity = $this->entity; + + if ($this->file) { + try { + $zip_path = $this->file->getFileUri(); + $entity->setArchive($zip_path); + } + catch (\Exception $e) { + $form_state->setErrorByName('file', $e->getMessage()); + return; + } + } + + $status = $entity->save(); + + switch ($status) { + case SAVED_NEW: + \Drupal::messenger()->addMessage($this->t('Created the %label Favicon Package.', [ + '%label' => $entity->label(), + ])); + break; + + default: + \Drupal::messenger()->addMessage($this->t('Saved the %label Favicon Package.', [ + '%label' => $entity->label(), + ])); + } + $form_state->setRedirectUrl($entity->toUrl('collection')); + } + +}