UrlHelper::siteUrl()   A
last analyzed

Complexity

Conditions 6
Paths 10

Size

Total Lines 26
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 42

Importance

Changes 0
Metric Value
eloc 15
c 0
b 0
f 0
dl 0
loc 26
ccs 0
cts 15
cp 0
rs 9.2222
cc 6
nc 10
nop 4
crap 42
1
<?php
2
/**
3
 * SEOmatic plugin for Craft CMS 3.x
4
 *
5
 * A turnkey SEO implementation for Craft CMS that is comprehensive, powerful,
6
 * and flexible
7
 *
8
 * @link      https://nystudio107.com
9
 * @copyright Copyright (c) 2017 nystudio107
10
 */
11
12
namespace nystudio107\seomatic\helpers;
13
14
use Craft;
15
use craft\errors\SiteNotFoundException;
16
use craft\helpers\UrlHelper as CraftUrlHelper;
17
use nystudio107\seomatic\Seomatic;
18
use Throwable;
19
use yii\base\Exception;
20
use function is_string;
21
22
/**
23
 * @author    nystudio107
24
 * @package   Seomatic
25
 * @since     3.0.0
26
 */
27
class UrlHelper extends CraftUrlHelper
28
{
29
    // Public Static Properties
30
    // =========================================================================
31
32
    // Public Static Methods
33
    // =========================================================================
34
35
    /**
36
     * @inheritDoc
37
     */
38
    public static function siteUrl(string $path = '', $params = null, string $scheme = null, int $siteId = null): string
39
    {
40
        try {
41
            $siteUrl = self::getSiteUrlOverrideSetting($siteId);
42
        } catch (Throwable $e) {
43
            // That's okay
44
        }
45
        if (!empty($siteUrl)) {
46
            $siteUrl = MetaValue::parseString($siteUrl);
47
            // Extract out just the path part
48
            $parts = self::decomposeUrl($path);
49
            $path = $parts['path'] . $parts['suffix'];
50
            $url = self::mergeUrlWithPath($siteUrl, $path);
51
            // Handle trailing slashes properly for generated URLs
52
            $generalConfig = Craft::$app->getConfig()->getGeneral();
53
            if ($generalConfig->addTrailingSlashesToUrls && !preg_match('/(.+\?.*)|(\.[^\/]+$)/', $url)) {
54
                $url = rtrim($url, '/') . '/';
55
            }
56
            if (!$generalConfig->addTrailingSlashesToUrls) {
57
                $url = rtrim($url, '/');
58
            }
59
60
            return DynamicMeta::sanitizeUrl(parent::urlWithParams($url, $params ?? []), false, false);
61
        }
62
63
        return DynamicMeta::sanitizeUrl(parent::siteUrl($path, $params, $scheme, $siteId), false, false);
64
    }
65
66
    /**
67
     * Merge the $url and $path together, combining any overlapping path segments
68
     *
69
     * @param string $url
70
     * @param string $path
71
     * @return string
72
     */
73
    public static function mergeUrlWithPath(string $url, string $path): string
74
    {
75
        $overlap = 0;
76
        $url = rtrim($url, '/') . '/';
77
        $path = '/' . ltrim($path, '/');
78
        $urlOffset = strlen($url);
79
        $pathLength = strlen($path);
80
        $pathOffset = 0;
81
        while ($urlOffset > 0 && $pathOffset < $pathLength) {
82
            $urlOffset--;
83
            $pathOffset++;
84
            if (str_starts_with($path, substr($url, $urlOffset, $pathOffset))) {
85
                $overlap = $pathOffset;
86
            }
87
        }
88
89
        return rtrim($url, '/') . '/' . ltrim(substr($path, $overlap), '/');
90
    }
91
92
    /**
93
     * Return the page trigger and the value of the page trigger (null if it doesn't exist)
94
     *
95
     * @return array
96
     */
97
    public static function pageTriggerValue(): array
98
    {
99
        $pageTrigger = Craft::$app->getConfig()->getGeneral()->pageTrigger;
100
        if (!is_string($pageTrigger) || $pageTrigger === '') {
101
            $pageTrigger = 'p';
102
        }
103
        // Is this query string-based pagination?
104
        if ($pageTrigger[0] === '?') {
105
            $pageTrigger = trim($pageTrigger, '?=');
106
        }
107
        // Avoid conflict with the path param
108
        $pathParam = Craft::$app->getConfig()->getGeneral()->pathParam;
109
        if ($pageTrigger === $pathParam) {
110
            $pageTrigger = $pathParam === 'p' ? 'pg' : 'p';
111
        }
112
        $pageTriggerValue = Craft::$app->getRequest()->getParam($pageTrigger);
113
114
        return [$pageTrigger, $pageTriggerValue];
115
    }
116
117
    /**
118
     * Return an absolute URL with protocol that curl will be happy with
119
     *
120
     * @param string $url
121
     *
122
     * @return string
123
     */
124
    public static function absoluteUrlWithProtocol($url): string
125
    {
126
        // Make this a full URL
127
        if (!self::isAbsoluteUrl($url)) {
128
            $protocol = 'http';
129
            if (isset($_SERVER['HTTPS']) && (strcasecmp($_SERVER['HTTPS'], 'on') === 0 || $_SERVER['HTTPS'] == 1)
0 ignored issues
show
introduced by
Consider adding parentheses for clarity. Current Interpretation: (IssetNode && strcasecmp...PROTO'], 'https') === 0, Probably Intended Meaning: IssetNode && (strcasecmp...ROTO'], 'https') === 0)
Loading history...
130
                || isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && strcasecmp($_SERVER['HTTP_X_FORWARDED_PROTO'], 'https') === 0
131
            ) {
132
                $protocol = 'https';
133
            }
134
            if (self::isProtocolRelativeUrl($url)) {
135
                try {
136
                    $url = self::urlWithScheme($url, $protocol);
137
                } catch (SiteNotFoundException $e) {
138
                    Craft::error($e->getMessage(), __METHOD__);
139
                }
140
            } else {
141
                try {
142
                    $url = self::siteUrl($url, null, $protocol);
143
                    if (self::isProtocolRelativeUrl($url)) {
144
                        $url = self::urlWithScheme($url, $protocol);
145
                    }
146
                } catch (Exception $e) {
147
                    Craft::error($e->getMessage(), __METHOD__);
148
                }
149
            }
150
        }
151
        // Ensure that any spaces in the URL are encoded
152
        $url = str_replace(' ', '%20', $url);
153
        // If the incoming URL has a trailing slash, respect it by preserving it
154
        $preserveTrailingSlash = false;
155
        if (str_ends_with($url, '/')) {
156
            $preserveTrailingSlash = true;
157
        }
158
        // Handle trailing slashes properly for generated URLs
159
        $generalConfig = Craft::$app->getConfig()->getGeneral();
160
        if ($generalConfig->addTrailingSlashesToUrls && !preg_match('/(.+\?.*)|(\.[^\/]+$)/', $url)) {
161
            $url = rtrim($url, '/') . '/';
162
        }
163
        if (!$generalConfig->addTrailingSlashesToUrls && (!$preserveTrailingSlash || self::urlIsSiteIndex($url))) {
164
            $url = rtrim($url, '/');
165
        }
166
167
        return DynamicMeta::sanitizeUrl($url, false, false);
168
    }
169
170
    /**
171
     * urlencode() just the query parameters in the URL
172
     *
173
     * @param string $url
174
     * @return string
175
     */
176 2
    public static function encodeUrlQueryParams(string $url): string
177
    {
178 2
        $urlParts = parse_url($url);
179 2
        $encodedUrl = "";
180 2
        if (isset($urlParts['scheme'])) {
181
            $encodedUrl .= $urlParts['scheme'] . '://';
182
        }
183 2
        if (isset($urlParts['host'])) {
184
            $encodedUrl .= $urlParts['host'];
185
        }
186 2
        if (isset($urlParts['port'])) {
187
            $encodedUrl .= ':' . $urlParts['port'];
188
        }
189 2
        if (isset($urlParts['path'])) {
190 2
            $encodedUrl .= $urlParts['path'];
191
        }
192 2
        if (isset($urlParts['query'])) {
193
            $query = explode('&', $urlParts['query']);
194
            foreach ($query as $j => $value) {
195
                $value = explode('=', $value, 2);
196
                if (count($value) === 2) {
197
                    $query[$j] = urlencode($value[0]) . '=' . urlencode($value[1]);
198
                } else {
199
                    $query[$j] = urlencode($value[0]);
200
                }
201
            }
202
            $encodedUrl .= '?' . implode('&', $query);
203
        }
204 2
        if (isset($urlParts['fragment'])) {
205
            $encodedUrl .= '#' . $urlParts['fragment'];
206
        }
207
208 2
        return $encodedUrl;
209
    }
210
211
    /**
212
     * Return whether this URL has a sub-directory as part of it
213
     *
214
     * @param string $url
215
     * @return bool
216
     */
217
    public static function urlHasSubDir(string $url): bool
218
    {
219
        return !empty(parse_url(trim($url, '/'), PHP_URL_PATH));
220
    }
221
222
    /**
223
     * See if the url is a site index, and if so, strip the trailing slash
224
     * ref: https://github.com/craftcms/cms/issues/5675
225
     *
226
     * @param string $url
227
     * @return bool
228
     */
229
    public static function urlIsSiteIndex(string $url): bool
230
    {
231
        $sites = Craft::$app->getSites()->getAllSites();
232
        $result = false;
233
        foreach ($sites as $site) {
234
            $sitePath = parse_url(self::siteUrl('/', null, null, $site->id), PHP_URL_PATH);
235
            if (!empty($sitePath)) {
236
                // Normalizes a URI path by trimming leading/ trailing slashes and removing double slashes
237
                $sitePath = '/' . preg_replace('/\/\/+/', '/', trim($sitePath, '/'));
238
            }
239
            // Normalizes a URI path by trimming leading/ trailing slashes and removing double slashes
240
            $url = '/' . preg_replace('/\/\/+/', '/', trim($url, '/'));
241
            // See if this url ends with a site prefix, and thus is a site index
242
            if (str_ends_with($url, $sitePath)) {
243
                $result = true;
244
            }
245
        }
246
247
        return $result;
248
    }
249
250
    /**
251
     * Return the siteUrlOverride setting, which can be a string or an array of site URLs
252
     * indexed by the site handle
253
     *
254
     * @param int|null $siteId
255
     * @return string
256
     * @throws Exception
257
     * @throws SiteNotFoundException
258
     */
259
    public static function getSiteUrlOverrideSetting(?int $siteId = null): string
260
    {
261
        // If the override is a string, just return it
262
        $siteUrlOverride = Seomatic::$settings->siteUrlOverride;
263
        if (is_string($siteUrlOverride)) {
264
            return $siteUrlOverride;
265
        }
266
        // If the override is an array, pluck the appropriate one by handle
267
        if (is_array($siteUrlOverride)) {
0 ignored issues
show
introduced by
The condition is_array($siteUrlOverride) is always true.
Loading history...
268
            $sites = Craft::$app->getSites();
269
            $site = $sites->getCurrentSite();
270
            if ($siteId !== null) {
271
                $site = $sites->getSiteById($siteId, true);
272
                if (!$site) {
273
                    throw new Exception('Invalid site ID: ' . $siteId);
274
                }
275
            }
276
277
            return $siteUrlOverride[$site->handle] ?? '';
278
        }
0 ignored issues
show
Bug Best Practice introduced by
The function implicitly returns null when the if condition on line 267 is false. This is incompatible with the type-hinted return string. Consider adding a return statement or allowing null as return value.

For hinted functions/methods where all return statements with the correct type are only reachable via conditions, ?null? gets implicitly returned which may be incompatible with the hinted type. Let?s take a look at an example:

interface ReturnsInt {
    public function returnsIntHinted(): int;
}

class MyClass implements ReturnsInt {
    public function returnsIntHinted(): int
    {
        if (foo()) {
            return 123;
        }
        // here: null is implicitly returned
    }
}
Loading history...
279
    }
280
281
    /**
282
     * Encodes non-alphanumeric characters in a URL, except reserved characters and already-encoded characters.
283
     *
284
     * @param string $url
285
     * @return string
286
     * @since 4.13.0
287
     */
288
    public static function encodeUrl(string $url): string
289
    {
290
        $parts = preg_split('/([:\/?#\[\]@!$&\'()*+,;=%])/', $url, -1, PREG_SPLIT_DELIM_CAPTURE);
291
        $url = '';
292
        foreach ($parts as $i => $part) {
293
            if ($i % 2 === 0) {
294
                $url .= urlencode($part);
295
            } else {
296
                $url .= $part;
297
            }
298
        }
299
        return $url;
300
    }
301
302
    // Protected Methods
303
    // =========================================================================
304
305
    /**
306
     * Decompose a url into a prefix, path, and suffix
307
     *
308
     * @param $pathOrUrl
309
     *
310
     * @return array
311
     */
312
    protected static function decomposeUrl($pathOrUrl): array
313
    {
314
        $result = array();
315
316
        if (filter_var($pathOrUrl, FILTER_VALIDATE_URL)) {
317
            $url_parts = parse_url($pathOrUrl);
318
            $result['prefix'] = $url_parts['scheme'] . '://' . $url_parts['host'];
319
            $result['path'] = $url_parts['path'] ?? '';
320
            $result['suffix'] = '';
321
            $result['suffix'] .= empty($url_parts['query']) ? '' : '?' . $url_parts['query'];
322
            $result['suffix'] .= empty($url_parts['fragment']) ? '' : '#' . $url_parts['fragment'];
323
        } else {
324
            $result['prefix'] = '';
325
            $result['path'] = $pathOrUrl;
326
            $result['suffix'] = '';
327
        }
328
329
        return $result;
330
    }
331
}
332