Skip to content

Commit 57af2f9

Browse files
author
Callin Mullaney
committed
refactor!: make favicon generation settings-driven
Move favicon form work into a dedicated class. Remove runtime fallback generation from page attachments. Attach only existing favicon packages at runtime. Add lifecycle docs and smoke coverage for the policy.
1 parent 7e07858 commit 57af2f9

11 files changed

Lines changed: 968 additions & 787 deletions

.github/scripts/favicon-portability-smoke.php

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
<?php
22

3+
/**
4+
* @file
5+
* Mode-driven favicon portability smoke helper.
6+
*/
7+
38
declare(strict_types=1);
49

510
use Drupal\emulsify\Favicon\FaviconPackageGenerator;
@@ -75,7 +80,10 @@ function emulsify_favicon_assert_invalid(callable $callback, string $expected_me
7580
$callback();
7681
}
7782
catch (\InvalidArgumentException $exception) {
78-
emulsify_favicon_assert(str_contains($exception->getMessage(), $expected_message), sprintf('Expected exception containing "%s", got "%s".', $expected_message, $exception->getMessage()));
83+
emulsify_favicon_assert(
84+
str_contains($exception->getMessage(), $expected_message),
85+
sprintf('Expected exception containing "%s", got "%s".', $expected_message, $exception->getMessage()),
86+
);
7987
return;
8088
}
8189

@@ -128,9 +136,12 @@ function emulsify_favicon_run_sanitizer_matrix(FaviconPackageGenerator $generato
128136
emulsify_favicon_assert(str_contains((string) $analysis['sanitized_svg'], 'linearGradient'), 'Inline gradients should be preserved.');
129137

130138
$analysis = $generator->validateSourceSvg($raster, FALSE);
131-
emulsify_favicon_assert(!empty($analysis['has_embedded_raster_images']), 'Base64 embedded raster images should be detected.');
139+
emulsify_favicon_assert(
140+
!empty($analysis['has_embedded_raster_images']),
141+
'Base64 embedded raster images should be detected.',
142+
);
132143

133-
// Dangerous markup should be stripped without rejecting otherwise usable SVGs.
144+
// Dangerous markup should be stripped without rejecting usable SVGs.
134145
$analysis = $generator->validateSourceSvg('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><script>alert(1)</script><rect width="64" height="64"/></svg>', FALSE);
135146
emulsify_favicon_assert(!str_contains((string) $analysis['sanitized_svg'], '<script'), 'Script tags should be stripped from sanitized SVG output.');
136147

.github/scripts/favicon-smoke.php

Lines changed: 69 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,23 @@
11
<?php
22

3+
/**
4+
* @file
5+
* Runtime favicon package attachment smoke helper.
6+
*/
7+
38
declare(strict_types=1);
49

510
use Drupal\emulsify\Favicon\FaviconPackageGenerator;
611
use Drupal\emulsify\Favicon\FaviconSettings;
712
use Drupal\emulsify\Hook\FaviconHooks;
813

914
/**
10-
* Smoke-tests runtime favicon package generation for a bootstrapped theme.
15+
* Smoke-tests favicon package attachment for a bootstrapped theme.
1116
*
12-
* favicon-smoke.sh executes this file through `drush php:eval`, so Drupal
17+
* Favicon-smoke.sh executes this file through `drush php:eval`, so Drupal
1318
* services, config, and theme hooks are available exactly as they are during a
14-
* request. The test stores a portable SVG in theme config, lets the page
15-
* attachment hook generate the package, then verifies files and head tags.
19+
* request. The test proves page attachments do not generate missing files,
20+
* then pre-generates a package and verifies existing package head tags.
1621
*/
1722
$argv = $_SERVER['argv'] ?? [];
1823
$theme_name = getenv('EMULSIFY_FAVICON_THEME');
@@ -29,17 +34,31 @@
2934
</svg>
3035
SVG;
3136

37+
/**
38+
* Fails the smoke script with a clear message.
39+
*/
3240
function emulsify_favicon_smoke_fail(string $message): void {
3341
fwrite(STDERR, $message . PHP_EOL);
3442
exit(1);
3543
}
3644

45+
/**
46+
* Asserts a condition and exits if it fails.
47+
*/
3748
function emulsify_favicon_smoke_assert(bool $condition, string $message): void {
3849
if (!$condition) {
3950
emulsify_favicon_smoke_fail($message);
4051
}
4152
}
4253

54+
/**
55+
* Loads normalized theme settings.
56+
*/
57+
function emulsify_favicon_smoke_load_settings(string $theme_name, string $site_name): array {
58+
$stored = \Drupal::configFactory()->get($theme_name . '.settings')->getRawData();
59+
return FaviconSettings::normalize(is_array($stored) ? $stored : [], $site_name);
60+
}
61+
4362
// The generator can store SVG config without these extensions, but producing
4463
// PNG/ICO files for the release smoke requires both rasterization stacks.
4564
if (!class_exists('Imagick')) {
@@ -50,8 +69,8 @@ function emulsify_favicon_smoke_assert(bool $condition, string $message): void {
5069
emulsify_favicon_smoke_fail('GD is required for favicon smoke tests.');
5170
}
5271

53-
// Reset package metadata before invoking the hook so this smoke proves package
54-
// creation rather than accepting files from a previous fixture run.
72+
// Reset package metadata before invoking the hook so this smoke proves page
73+
// requests do not create missing package files.
5574
$config = \Drupal::configFactory()->getEditable($theme_name . '.settings');
5675
$config
5776
->set('favicon_package_enabled', TRUE)
@@ -74,9 +93,23 @@ function emulsify_favicon_smoke_assert(bool $condition, string $message): void {
7493
->set('favicon_package_generated_at', 0)
7594
->save();
7695

77-
/** @var \Drupal\Core\Theme\ThemeInitializationInterface $theme_initialization */
96+
$generator = new FaviconPackageGenerator(
97+
\Drupal::service('file_system'),
98+
\Drupal::service('file_url_generator'),
99+
\Drupal::service('config.factory'),
100+
\Drupal::service('cache_tags.invalidator'),
101+
\Drupal::service('datetime.time'),
102+
\Drupal::service('lock'),
103+
);
104+
$settings = emulsify_favicon_smoke_load_settings($theme_name, $site_name);
105+
$definition = $generator->getPackageDefinition($theme_name, $settings, $svg);
106+
$realpath = \Drupal::service('file_system')->realpath($definition['path']);
107+
if ($realpath && is_dir($realpath)) {
108+
\Drupal::service('file_system')->deleteRecursive($realpath);
109+
}
110+
78111
$theme_initialization = \Drupal::service('theme.initialization');
79-
/** @var \Drupal\Core\Theme\ThemeManagerInterface $theme_manager */
112+
80113
$theme_manager = \Drupal::service('theme.manager');
81114
$theme_manager->setActiveTheme($theme_initialization->initTheme($theme_name));
82115

@@ -90,28 +123,31 @@ function emulsify_favicon_smoke_assert(bool $condition, string $message): void {
90123
],
91124
];
92125

93-
/** @var \Drupal\emulsify\Hook\FaviconHooks $hook_handler */
94126
$hook_handler = \Drupal::service('class_resolver')->getInstanceFromDefinition(FaviconHooks::class);
95127
$hook_handler->pageAttachmentsAlter($attachments);
96128

97-
$settings = [];
98-
foreach (FaviconSettings::DEFAULTS as $key => $default) {
99-
$settings[$key] = $config->get($key);
100-
}
101-
$settings = FaviconSettings::normalize($settings, $site_name);
129+
emulsify_favicon_smoke_assert(!$generator->packageExists($definition['path']), 'Runtime page attachments must not generate missing favicon package files.');
130+
emulsify_favicon_smoke_assert($attachments['#attached']['html_head_link'] === [], 'Runtime page attachments should skip missing favicon packages.');
131+
emulsify_favicon_smoke_assert($attachments['#attached']['html_head'] === [], 'Runtime page attachments should not add favicon metadata for missing packages.');
102132

103-
$generator = new FaviconPackageGenerator(
104-
\Drupal::service('file_system'),
105-
\Drupal::service('file_url_generator'),
106-
\Drupal::service('config.factory'),
107-
\Drupal::service('cache_tags.invalidator'),
108-
\Drupal::service('datetime.time'),
109-
\Drupal::service('lock'),
133+
$result = $generator->generateFromSvg(
134+
$theme_name,
135+
$svg,
136+
$settings,
137+
[
138+
'filename' => 'release-check.svg',
139+
],
110140
);
111-
$definition = $generator->getPackageDefinition($theme_name, $settings, $svg);
112-
$realpath = \Drupal::service('file_system')->realpath($definition['path']);
141+
$config = \Drupal::configFactory()->getEditable($theme_name . '.settings');
142+
$config
143+
->set('favicon_package_hash', $result['hash'])
144+
->set('favicon_package_path', $result['path'])
145+
->set('favicon_package_generated_at', $result['generated_at'])
146+
->save();
147+
148+
$realpath = \Drupal::service('file_system')->realpath($result['path']);
113149

114-
emulsify_favicon_smoke_assert((bool) $realpath && is_dir($realpath), "Expected generated favicon package at {$definition['path']}.");
150+
emulsify_favicon_smoke_assert((bool) $realpath && is_dir($realpath), "Expected generated favicon package at {$result['path']}.");
115151

116152
$expected_files = [
117153
'favicon.svg',
@@ -135,6 +171,14 @@ function emulsify_favicon_smoke_assert(bool $condition, string $message): void {
135171
emulsify_favicon_smoke_assert(($manifest_data['display'] ?? '') === 'standalone', 'Generated site.webmanifest is missing the standalone display mode.');
136172
emulsify_favicon_smoke_assert(($manifest_data['theme_color'] ?? '') === '#005b99', 'Generated site.webmanifest is missing the expected theme color.');
137173

174+
$attachments = [
175+
'#attached' => [
176+
'html_head' => [],
177+
'html_head_link' => [],
178+
],
179+
];
180+
$hook_handler->pageAttachmentsAlter($attachments);
181+
138182
$rels = [];
139183
foreach ($attachments['#attached']['html_head_link'] as $item) {
140184
$rel = $item[0]['rel'] ?? NULL;
@@ -157,4 +201,4 @@ function emulsify_favicon_smoke_assert(bool $condition, string $message): void {
157201
emulsify_favicon_smoke_assert(in_array($required_meta, $meta_names, TRUE), "Missing {$required_meta} meta tag.");
158202
}
159203

160-
fwrite(STDOUT, "Verified favicon package generation and head attachments at {$realpath}.\n");
204+
fwrite(STDOUT, "Verified favicon package attachment without runtime generation at {$realpath}.\n");

docs/favicon-generation.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Favicon Package Generation
2+
3+
Emulsify 7.x generates a complete favicon package from one SVG source configured in the Drupal theme settings form for `emulsify` or an Emulsify child theme.
4+
5+
## Requirements
6+
7+
Generated PNG and ICO assets require both PHP extensions:
8+
9+
- GD
10+
- Imagick
11+
12+
The uploaded source must be an SVG file with a square `viewBox`. If root `width` and `height` values are present, they must also describe a square. The generator accepts embedded raster image data inside the SVG, but the theme settings UI warns that fully vector sources usually scale more cleanly.
13+
14+
Source uploads are limited to 5 MB. The sanitized portable SVG copy is stored in theme config for portability; copies larger than 256 KB are allowed but flagged as review noise because they make config exports harder to inspect.
15+
16+
## Generated Package
17+
18+
Packages are written to the public files directory using a deterministic hash:
19+
20+
```text
21+
public://favicon-package/<theme_name>/<package_hash>
22+
```
23+
24+
The hash is derived from the sanitized source SVG and favicon rendering settings. The package path, hash, timestamp, sanitized SVG source, and source filename are stored in `<theme>.settings` so the package can be regenerated in another environment after config import.
25+
26+
Each generated package contains:
27+
28+
- `favicon.svg`
29+
- `favicon.ico`
30+
- `favicon-96x96.png`
31+
- `apple-touch-icon.png`
32+
- `web-app-manifest-192x192.png`
33+
- `web-app-manifest-512x512.png`
34+
- `web-app-manifest-512x512-maskable.png`
35+
- `site.webmanifest`
36+
- `metadata.json`
37+
38+
`metadata.json` records the theme name, package hash, generation timestamp, source metadata, normalized favicon settings, source warnings, and generated file list. It is used as the package existence marker.
39+
40+
Do not manually edit generated package files. Change the source SVG or favicon settings, then regenerate the package so metadata, hash, and head attachments stay consistent.
41+
42+
## Lifecycle
43+
44+
Generated favicon packages are environment-local build artifacts. They are expected to exist in each deployed environment, but they should be recreated from configuration rather than treated as hand-maintained source files.
45+
46+
Generation happens only in these workflows:
47+
48+
1. Save the Emulsify Drupal theme settings form after configuring or changing favicon-related settings.
49+
2. Run the Emulsify Tools Drush generate command after deploy or config import:
50+
51+
```bash
52+
drush emulsify_tools:favicon-generate [theme_name]
53+
```
54+
55+
Use Emulsify Tools for deployment diagnostics and reset workflows:
56+
57+
```bash
58+
drush emulsify_tools:favicon-status [theme_name]
59+
drush emulsify_tools:favicon-reset [theme_name]
60+
```
61+
62+
Normal page requests do not create, modify, or regenerate favicon files. At runtime, Emulsify only attaches head tags for an existing generated package. If the configured package path is missing, page rendering continues without generated favicon head tags until the theme settings form or Emulsify Tools command creates the package.

src/Favicon/FaviconHeadBuilder.php

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
<?php
22

3+
declare(strict_types=1);
4+
35
namespace Drupal\emulsify\Favicon;
46

57
use Drupal\Core\File\FileUrlGeneratorInterface;
@@ -39,31 +41,36 @@ public function apply(array &$attachments, array $settings): void {
3941
'rel' => 'icon',
4042
'href' => $this->fileUrlGenerator->generateString($package_path . '/favicon.ico'),
4143
'sizes' => 'any',
42-
], 'emulsify_favicon_ico'];
44+
], 'emulsify_favicon_ico',
45+
];
4346

4447
$attachments['#attached']['html_head_link'][] = [[
4548
'rel' => 'icon',
4649
'type' => 'image/svg+xml',
4750
'href' => $this->fileUrlGenerator->generateString($package_path . '/favicon.svg'),
48-
], 'emulsify_favicon_svg'];
51+
], 'emulsify_favicon_svg',
52+
];
4953

5054
$attachments['#attached']['html_head_link'][] = [[
5155
'rel' => 'apple-touch-icon',
5256
'href' => $this->fileUrlGenerator->generateString($package_path . '/apple-touch-icon.png'),
53-
], 'emulsify_favicon_ios'];
57+
], 'emulsify_favicon_ios',
58+
];
5459

5560
$attachments['#attached']['html_head_link'][] = [[
5661
'rel' => 'manifest',
5762
'href' => $this->fileUrlGenerator->generateString($package_path . '/site.webmanifest'),
58-
], 'emulsify_favicon_manifest'];
63+
], 'emulsify_favicon_manifest',
64+
];
5965

6066
$attachments['#attached']['html_head'][] = [[
6167
'#tag' => 'meta',
6268
'#attributes' => [
6369
'name' => 'theme-color',
6470
'content' => $theme_color,
6571
],
66-
], 'emulsify_favicon_theme_color'];
72+
], 'emulsify_favicon_theme_color',
73+
];
6774

6875
if ($icon_name !== '') {
6976
$attachments['#attached']['html_head'][] = [[
@@ -72,7 +79,8 @@ public function apply(array &$attachments, array $settings): void {
7279
'name' => 'apple-mobile-web-app-title',
7380
'content' => $icon_name,
7481
],
75-
], 'emulsify_favicon_ios_title'];
82+
], 'emulsify_favicon_ios_title',
83+
];
7684
}
7785
}
7886

0 commit comments

Comments
 (0)