Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
48 / 48
100.00% covered (success)
100.00%
24 / 24
CRAP
100.00% covered (success)
100.00%
1 / 1
FeaturedImage
100.00% covered (success)
100.00%
48 / 48
100.00% covered (success)
100.00%
24 / 24
34
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 __toString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSource
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setSource
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getContentLength
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMetadataArray
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getAltText
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTitleText
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAuthorName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAuthorUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCopyrightText
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLicenseName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLicenseUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasAltText
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasTitleText
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasAuthorName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasAuthorUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasCopyrightText
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasLicenseName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasLicenseUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 has
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getContentLengthForLocalImage
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getContentLengthForRemoteImage
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 isRemote
n/a
0 / 0
n/a
0 / 0
1
1<?php
2
3declare(strict_types=1);
4
5namespace Hyde\Framework\Features\Blogging\Models;
6
7use Hyde\Hyde;
8use Stringable;
9use Hyde\Facades\Config;
10use Illuminate\Support\Str;
11use Hyde\Support\BuildWarnings;
12use JetBrains\PhpStorm\Deprecated;
13use Illuminate\Support\Facades\Http;
14use Hyde\Foundation\Kernel\Hyperlinks;
15use Hyde\Framework\Exceptions\FileNotFoundException;
16use Hyde\Markdown\Contracts\FrontMatter\SubSchemas\FeaturedImageSchema;
17
18use function array_key_exists;
19use function array_flip;
20use function file_exists;
21use function filesize;
22use function sprintf;
23use function key;
24
25/**
26 * Object representation of a blog post's featured image.
27 *
28 * While the object can of course be used for any other page type,
29 * it is named "FeaturedImage" as it's only usage within Hyde
30 * is for the featured image of a Markdown blog post.
31 *
32 * @see \Hyde\Framework\Factories\FeaturedImageFactory
33 */
34class FeaturedImage implements Stringable, FeaturedImageSchema
35{
36    /**
37     * A featured image object, for a file stored locally.
38     *
39     * The internal data structure forces the image source to reference a file in the _media directory,
40     * and thus that is what is required for the input. However, when outputting data, the data will
41     * be used for the _site/media directory, so it will provide data relative to the site root.
42     *
43     * The source information is stored in $this->source, which is a file in the _media directory.
44     */
45    protected final const TYPE_LOCAL = 'local';
46
47    /**
48     * A featured image object, for a file stored remotely.
49     */
50    protected final const TYPE_REMOTE = 'remote';
51
52    /** @var self::TYPE_* */
53    protected readonly string $type;
54
55    protected readonly string $source;
56
57    public function __construct(
58        string $source,
59        protected readonly ?string $altText = null,
60        protected readonly ?string $titleText = null,
61        protected readonly ?string $authorName = null,
62        protected readonly ?string $authorUrl = null,
63        protected readonly ?string $licenseName = null,
64        protected readonly ?string $licenseUrl = null,
65        protected readonly ?string $copyrightText = null
66    ) {
67        $this->type = Hyperlinks::isRemote($source) ? self::TYPE_REMOTE : self::TYPE_LOCAL;
68        $this->source = $this->setSource($source);
69    }
70
71    public function __toString(): string
72    {
73        return $this->getSource();
74    }
75
76    /**
77     * Get the source of the image, must be usable within the src attribute of an image tag,
78     * and is thus not necessarily the path to the source image on disk.
79     *
80     * @return string The image's url or path
81     */
82    public function getSource(): string
83    {
84        if ($this->type === self::TYPE_LOCAL) {
85            // Return value is always resolvable from a compiled page in the _site directory.
86            return Hyde::mediaLink($this->source);
87        }
88
89        return $this->source;
90    }
91
92    protected function setSource(string $source): string
93    {
94        if ($this->type === self::TYPE_LOCAL) {
95            // Normalize away any leading media path prefixes.
96            return Str::after($source, Hyde::getMediaDirectory().'/');
97        }
98
99        return $source;
100    }
101
102    public function getContentLength(): int
103    {
104        if ($this->type === self::TYPE_LOCAL) {
105            return $this->getContentLengthForLocalImage();
106        }
107
108        return $this->getContentLengthForRemoteImage();
109    }
110
111    /** @return self::TYPE_* */
112    public function getType(): string
113    {
114        return $this->type;
115    }
116
117    /**
118     * Used in resources/views/components/post/image.blade.php to add meta tags with itemprop attributes.
119     *
120     * @return array{text?: string|null, name?: string|null, url: string, contentUrl: string}
121     */
122    public function getMetadataArray(): array
123    {
124        $metadata = [];
125
126        if ($this->hasAltText()) {
127            $metadata['text'] = $this->getAltText();
128        }
129
130        if ($this->hasTitleText()) {
131            $metadata['name'] = $this->getTitleText();
132        }
133
134        $metadata['url'] = $this->getSource();
135        $metadata['contentUrl'] = $this->getSource();
136
137        return $metadata;
138    }
139
140    public function getAltText(): ?string
141    {
142        return $this->altText;
143    }
144
145    public function getTitleText(): ?string
146    {
147        return $this->titleText;
148    }
149
150    public function getAuthorName(): ?string
151    {
152        return $this->authorName;
153    }
154
155    public function getAuthorUrl(): ?string
156    {
157        return $this->authorUrl;
158    }
159
160    public function getCopyrightText(): ?string
161    {
162        return $this->copyrightText;
163    }
164
165    public function getLicenseName(): ?string
166    {
167        return $this->licenseName;
168    }
169
170    public function getLicenseUrl(): ?string
171    {
172        return $this->licenseUrl;
173    }
174
175    public function hasAltText(): bool
176    {
177        return $this->has('altText');
178    }
179
180    public function hasTitleText(): bool
181    {
182        return $this->has('titleText');
183    }
184
185    public function hasAuthorName(): bool
186    {
187        return $this->has('authorName');
188    }
189
190    public function hasAuthorUrl(): bool
191    {
192        return $this->has('authorUrl');
193    }
194
195    public function hasCopyrightText(): bool
196    {
197        return $this->has('copyrightText');
198    }
199
200    public function hasLicenseName(): bool
201    {
202        return $this->has('licenseName');
203    }
204
205    public function hasLicenseUrl(): bool
206    {
207        return $this->has('licenseUrl');
208    }
209
210    protected function has(string $property): bool
211    {
212        return $this->$property !== null;
213    }
214
215    protected function getContentLengthForLocalImage(): int
216    {
217        $storagePath = Hyde::mediaPath($this->source);
218
219        if (! file_exists($storagePath)) {
220            throw new FileNotFoundException(customMessage: sprintf('Featured image [%s] not found.', Hyde::pathToRelative($storagePath)));
221        }
222
223        return filesize($storagePath);
224    }
225
226    protected function getContentLengthForRemoteImage(): int
227    {
228        // API calls can be skipped in the config, or by setting the --no-api flag when running the build command.
229        if (Config::getBool('hyde.api_calls', true)) {
230            /** @var string[][] $headers */
231            $headers = Http::withHeaders([
232                'User-Agent' => Config::getString('hyde.http_user_agent', 'RSS Request Client'),
233            ])->head($this->getSource())->headers();
234
235            if (array_key_exists('Content-Length', $headers)) {
236                return (int) key(array_flip($headers['Content-Length']));
237            }
238
239            BuildWarnings::report('The image "'.$this->getSource().'" has a content length of zero.');
240        }
241
242        return 0;
243    }
244
245    /**
246     * @codeCoverageIgnore Deprecated method.
247     *
248     * @deprecated This method will be removed in v2.0. Please use `Hyperlinks::isRemote` instead.
249     */
250    #[Deprecated(reason: 'Replaced by the \Hyde\Foundation\Kernel\Hyperlinks::isRemote method', replacement: '\Hyde\Foundation\Kernel\Hyperlinks::isRemote(%parametersList%)', since: '1.8.0')]
251    public static function isRemote(string $source): bool
252    {
253        return Hyperlinks::isRemote($source);
254    }
255}