MarkupSitemap.module.php 12.8 KB
Newer Older
Mike Rockétt's avatar
init  
Mike Rockétt committed
1
2
<?php

Mike Rockétt's avatar
Mike Rockétt committed
3
4
5
6
/**
 * Sitemap for ProcessWire
 * Module class
 *
7
8
 * @author Mike Rockett <mike@rockett.pw>
 * @copyright 2017-19
Mike Rockétt's avatar
Mike Rockétt committed
9
 * @license ISC
Mike Rockétt's avatar
Mike Rockétt committed
10
11
 */

12
13
14
15
16
17
18
19
// Require the classloaders
wire('classLoader')->addNamespace('Thepixeldeveloper\Sitemap', __DIR__ . '/src/Sitemap');
wire('classLoader')->addNamespace('Rockett\Concerns', __DIR__ . '/src/Concerns');

use Thepixeldeveloper\Sitemap\Url;
use Thepixeldeveloper\Sitemap\Urlset;
use Thepixeldeveloper\Sitemap\Extensions\Link;
use Thepixeldeveloper\Sitemap\Drivers\XmlWriterDriver;
Mike Rockétt's avatar
init  
Mike Rockétt committed
20

21
22
use Rockett\Concerns;
use Rockett\Support\ParseFloat;
Mike Rockétt's avatar
init  
Mike Rockétt committed
23
24
25

class MarkupSitemap extends WireData implements Module
{
26
27
28
29
30
31
  use Concerns\DebugsThings;
  use Concerns\BuildsInputFields;
  use Concerns\ConfiguresTabs;
  use Concerns\ProcessesTabs;
  use Concerns\HandlesEvents;
  use Concerns\SupportsImages;
Mike Rockétt's avatar
Mike Rockétt committed
32
33
34
35
36

  /**
   * Image fields: each field is mapped to the relavent
   * function for the Image sub-element
   */
37
  private static $imageFields = [
38
39
40
    'Caption' => 'description',
    'License' => 'license',
    'Title' => 'title',
Mike Rockétt's avatar
Mike Rockétt committed
41
42
43
    'GeoLocation' => 'geo|location|geolocation',
  ];

44
  /**
45
   * Sitemap URI
Mike Rockétt's avatar
Mike Rockétt committed
46
   */
47
  const sitemapUri = '/sitemap.xml';
Mike Rockétt's avatar
Mike Rockétt committed
48
49
50

  /**
   * Current request URI
51
   *
Mike Rockétt's avatar
Mike Rockétt committed
52
53
54
55
56
   * @var string
   */
  protected $requestUri = '';

  /**
57
   * Current UrlSet
Mike Rockétt's avatar
Mike Rockétt committed
58
   *
59
   * @var Urlset
Mike Rockétt's avatar
Mike Rockétt committed
60
   */
61
  protected $urlSet;
Mike Rockétt's avatar
Mike Rockétt committed
62
63
64
65
66
67
68
69
70
71

  /**
   * Module installer
   * Requires ProcessWire 2.8.16+/3.0.16+ (saveConfig; getConfig)
   * @throws WireException
   */
  public function ___install()
  {
    $processWireVersion = $this->config->version;
    $applicableMajorMinor = ProcessWire::versionMajor === 2 ? '2.8' : '3.0';
72

Mike Rockétt's avatar
Mike Rockétt committed
73
74
    if (version_compare($processWireVersion, "{$applicableMajorMinor}.16") < 0) {
      throw new WireException("Requires ProcessWire {$applicableMajorMinor}.16+ to run.");
Mike Rockétt's avatar
init  
Mike Rockétt committed
75
    }
Mike Rockétt's avatar
Mike Rockétt committed
76
77
78
79
80
81
82
83
84
85
86
87
88
  }

  /**
   * Class constructor
   * Get and assign the current request URI
   */
  public function __construct()
  {
    $this->requestUri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : null;
  }

  /**
   * Return a POSTed value or its default if not available
89
90
91
   *
   * @var string $valueKey
   * @var mixed $default
Mike Rockétt's avatar
Mike Rockétt committed
92
93
94
95
96
97
98
99
   * @return mixed
   */
  public function getPostedValue($valueKey, $default = false)
  {
    return $this->input->post->$valueKey ?: $default;
  }

  /**
100
   * Initialize the module
Mike Rockétt's avatar
Mike Rockétt committed
101
102
103
   *
   * @return void
   */
104
  public function init(): void
Mike Rockétt's avatar
Mike Rockétt committed
105
106
107
108
109
110
111
112
113
114
115
  {
    // If the request is valid (/sitemap.xml)...
    if ($this->isValidRequest()) {
      // Add the relevant page hooks for multi-language support
      // as these are not bootstrapped at the 404 event (for some reason...)
      if ($this->siteUsesLanguageSupportPageNames()) {
        foreach (['localHttpUrl', 'localName'] as $pageHook) {
          $pageHookFunction = 'hookPage' . ucfirst($pageHook);
          $this->addHook("Page::{$pageHook}", null, function ($event) use ($pageHookFunction) {
            $this->modules->LanguageSupportPageNames->{$pageHookFunction}($event);
          });
Mike Rockétt's avatar
Mike Rockétt committed
116
        }
Mike Rockétt's avatar
Mike Rockétt committed
117
      }
118

Mike Rockétt's avatar
Mike Rockétt committed
119
120
      // Add the hook to process and render the sitemap.
      $this->addHookBefore('ProcessPageView::pageNotFound', $this, 'render');
Mike Rockétt's avatar
init  
Mike Rockétt committed
121
122
    }

Mike Rockétt's avatar
Mike Rockétt committed
123
124
125
126
    // Add hook to render Sitemap fields on the Settings tab of each page
    if ($this->user->hasPermission('page-edit')) {
      $this->addHookAfter('ProcessPageEdit::buildFormSettings', $this, 'setupSettingsTab');
      $this->addHookAfter('ProcessPageEdit::processInput', $this, 'processSettingsTab');
Mike Rockétt's avatar
init  
Mike Rockétt committed
127
    }
128

Mike Rockétt's avatar
Mike Rockétt committed
129
130
131
132
    // If the user can delete pages, then we need to hook into delete
    // events to remove sitemap options for deleted pages
    if ($this->user->hasPermission('page-delete')) {
      $this->addHookAfter('Pages::deleted', $this, 'deletePageSitemapOptions');
Mike Rockétt's avatar
init  
Mike Rockétt committed
133
    }
Mike Rockétt's avatar
Mike Rockétt committed
134
135
136
  }

  /**
137
   * Initialize the sitemap render by getting the root URI (giving
Mike Rockétt's avatar
Mike Rockétt committed
138
139
140
   * consideration to multi-site setups) and passing it to the
   * first/parent recursive render-method (addPages).
   *
141
142
143
   * Depending on config settings entire sitemap is cached using MarkupCache or
   * WireCache, and the cache is destroyed when settings are saved and, if set
   * up, a page is saved.
Mike Rockétt's avatar
Mike Rockétt committed
144
145
   *
   * @param HookEvent $event
146
   * @return void
Mike Rockétt's avatar
Mike Rockétt committed
147
   */
148
  public function render(HookEvent $event): void
Mike Rockétt's avatar
Mike Rockétt committed
149
150
151
152
153
154
155
156
157
158
  {
    // Get the initial root URI.
    $rootPage = $this->getRootPageUri();

    // If multi-site is present and active, prepend the subdomain prefix.
    if ($this->modules->isInstalled('MultiSite')) {
      $multiSite = $this->modules->get('MultiSite');
      if ($multiSite->subdomain) {
        $rootPage = "/{$multiSite->subdomain}{$rootPage}";
      }
159
160
    }

Mike Rockétt's avatar
Mike Rockétt committed
161
162
    // Make sure that the root page exists.
    if (!$this->pages->get($rootPage) instanceof NullPage) {
163
164
      // Get cached sitemap
      $event->return = $this->getCached($rootPage);
Mike Rockétt's avatar
Mike Rockétt committed
165
      header('Content-Type: application/xml', true, 200);
166

Mike Rockétt's avatar
Mike Rockétt committed
167
168
      // Prevent further hooks. This stops
      // SystemNotifications from displaying a 404 event
169
      // when /sitemap.xml is requested. Additionally,
Mike Rockétt's avatar
Mike Rockétt committed
170
171
172
173
174
175
      // it prevents further modification to the sitemap.
      $event->replace = true;
      $event->cancelHooks = true;
    }
  }

176
177
178
179
180
181
  /**
   * Get cached sitemap markup
   *
   * @param string $rootPage
   * @return string
   */
182
  protected function getCached($rootPage): string
183
184
185
186
187
188
189
190
  {
    // Bail out early if debug mode is enabled
    if ($this->config->debug) {
      header('X-Cached-Sitemap: no');
      return $this->buildNewSitemap($rootPage);
    }

    // Cache settings
191
    $cacheTtl = $this->cache_ttl ?: 3600;
192
193
194
195
196
    $cacheKey = 'MarkupSitemap';
    $cacheMethod = $this->cache_method ?: 'MarkupCache';

    // Attempt to fetch sitemap from cache
    $cache = $cacheMethod == 'WireCache' ? $this->cache : $this->modules->MarkupCache;
197
    $output = $cache->get($cacheKey, $cacheTtl);
198
199
200
201
202
203

    // If output is empty, generate and cache new sitemap
    if (empty($output)) {
      header('X-Cached-Sitemap: no');
      $output = $this->buildNewSitemap($rootPage);
      if ($cacheMethod == 'WireCache') {
204
        $cache->save($cacheKey, $output, $cacheTtl);
205
206
207
208
209
210
211
212
213
214
      } else {
        $cache->save($output);
      }
      return $output;
    }

    header('X-Cached-Sitemap: yes');
    return $output;
  }

Mike Rockétt's avatar
Mike Rockétt committed
215
216
  /**
   * Get the root page URI
217
   *
Mike Rockétt's avatar
Mike Rockétt committed
218
219
   * @return string
   */
220
  protected function getRootPageUri(): string
Mike Rockétt's avatar
Mike Rockétt committed
221
222
223
224
225
226
227
228
229
230
  {
    return (string) str_ireplace(
      trim($this->config->urls->root, '/'),
      '',
      $this->sanitizer->path(dirname($this->requestUri))
    );
  }

  /**
   * Determine if the request is valud
231
   *
Mike Rockétt's avatar
Mike Rockétt committed
232
233
   * @return boolean
   */
234
  protected function isValidRequest(): bool
Mike Rockétt's avatar
Mike Rockétt committed
235
236
237
  {
    $valid = (bool) (
      $this->requestUri !== null &&
238
      strlen($this->requestUri) - strlen(self::sitemapUri) === strrpos($this->requestUri, self::sitemapUri)
Mike Rockétt's avatar
Mike Rockétt committed
239
240
241
242
243
244
245
246
    );

    return $valid;
  }

  /**
   * Check if the language is not default and that the
   * page is not available/statused in the default language.
247
   *
Mike Rockétt's avatar
Mike Rockétt committed
248
249
250
251
   * @param  string $language
   * @param  Page   $page
   * @return bool
   */
252
  protected function pageLanguageInvalid($language, $page): bool
Mike Rockétt's avatar
Mike Rockétt committed
253
254
255
256
257
258
  {
    return (!$language->isDefault() && !$page->{"status{$language->id}"});
  }

  /**
   * Determine if the site uses the LanguageSupportPageNames module.
259
   *
Mike Rockétt's avatar
Mike Rockétt committed
260
261
   * @return bool
   */
262
  protected function siteUsesLanguageSupportPageNames(): bool
Mike Rockétt's avatar
Mike Rockétt committed
263
264
265
  {
    return $this->modules->isInstalled('LanguageSupportPageNames');
  }
266
267
268
269
270

  /**
   * Add alternative languges, including current.
   * @param Page $page
   * @param Url  $url
271
   * @return void
272
   */
273
  protected function addAltLanguages($page, $url): void
274
275
276
277
278
279
280
281
282
283
284
285
286
  {
    foreach ($this->languages as $altLanguage) {
      if ($this->pageLanguageInvalid($altLanguage, $page)) {
        continue;
      }
      if ($altLanguage->isDefault()
        && $this->pages->get(1)->name === 'home'
        && !$this->modules->LanguageSupportPageNames->useHomeSegment
        && !empty($this->sitemap_default_iso)) {
        $languageIsoName = $this->sitemap_default_iso;
      } else {
        $languageIsoName = $this->pages->get(1)->localName($altLanguage);
      }
287
      $url->addExtension(new Link($languageIsoName, $page->localHttpUrl($altLanguage)));
288
289
290
291
292
    }
  }

  /**
   * Determine if a page can be included in the sitemap
293
   *
294
295
296
297
   * @param  $page
   * @param  $options
   * @return bool
   */
298
  public function canBeIncluded($page, $options): bool
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
  {
    // If it's the home page, it's always includible.
    if ($page->id === 1) {
      return true;
    }

    // If the page's template is excluded from accessing Sitemap,
    // then it's not includible.
    if (in_array($page->template->name, $this->sitemap_exclude_templates)) {
      return false;
    }

    // Otherwise, check to see if the page itself has been excluded
    // via Sitemap options.
    return !$options['excludes']['page'];
  }

  /**
   * Recursively add pages in each language with
   * alternate language and image sub-elements.
319
   *
320
   * @param  $page
321
   * @return void
322
   */
323
  protected function addPages($page): void
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
  {
    // Get the saved options for this page
    $pageSitemapOptions = $this->modules->getConfig($this, "o{$page->id}");

    // If the template that this page belongs to is not using sitemap options
    // (per the module's current configuration), then we need to revert the keys
    // in $pageSitemapOptions to their defaults so as to prevent their
    // saved options from being used in this cycle.
    if ($this->sitemap_include_templates !== null
      && !in_array($page->template->name, $this->sitemap_include_templates)
      && is_array($pageSitemapOptions)) {
      array_walk_recursive($pageSitemapOptions, function (&$value) {
        $value = false;
      });
    }

    // If the page is viewable and not excluded or we’re working with the root page,
    // begin generating the sitemap by adding pages recursively. (Root is always added.)
342
    if ($page->viewable() && $this->canBeIncluded($page, $pageSitemapOptions)) {
343
344
345
346
347
348
349
350
      // If language support is enabled, then we need to loop through each language
      // to generate <loc> for each language with all alternates, including the
      // current language. Then add image references with multi-language support.
      if ($this->siteUsesLanguageSupportPageNames()) {
        foreach ($this->languages as $language) {
          if ($this->pageLanguageInvalid($language, $page) || !$page->viewable($language)) {
            continue;
          }
351

352
          $url = new Url($page->localHttpUrl($language));
353
          $url->setLastMod(new DateTime(date('c', $page->modified)));
354
          $this->addAltLanguages($page, $url);
355

356
          if ($pageSitemapOptions['priority']) {
357
            $url->setPriority(ParseFloat::asString($pageSitemapOptions['priority']));
358
          }
359

360
361
362
          if (!$pageSitemapOptions['excludes']['images']) {
            $this->addImages($page, $url, $language);
          }
363
364

          $this->urlSet->add($url);
365
366
367
368
369
        }
      } else {
        // If multi-language support is not enabled, then we only need to
        // add the current URL to a new <loc>, along with images.
        $url = new Url($page->httpUrl);
370
371
        $url->setLastMod(new DateTime(date('c', $page->modified)));

372
        if ($pageSitemapOptions['priority']) {
373
          $url->setPriority(ParseFloat::asString($pageSitemapOptions['priority']));
374
        }
375

376
377
378
        if (!$pageSitemapOptions['excludes']['images']) {
          $this->addImages($page, $url);
        }
379
380

        $this->urlSet->add($url);
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
      }
    }

    // Check for children
    if (!$pageSitemapOptions['excludes']['children']) {

      // Build up the child selector.
      $selector = "id!={$this->config->http404PageID}";
      if ($this->sitemap_include_hidden) {
        $selector = implode(',', [
          'include=hidden',
          'template!=admin',
          $selector,
        ]);
      }

      // Check for children and include where possible.
      if ($page->hasChildren($selector)) {
        foreach ($page->children($selector) as $child) {
          $this->addPages($child);
        }
      }
    }
  }

  /**
   * Build a new sitemap (called when cache doesn't have one or we're debugging)
408
   *
409
410
   * @return string
   */
411
  protected function buildNewSitemap($rootPage): string
412
413
414
  {
    $this->urlSet = new Urlset();
    $this->addPages($this->pages->get($rootPage));
415
416
417
418
419
    $writer = new XmlWriterDriver();

    $timestamp = date('c');
    $writer->addComment("Last generated: $timestamp");

420
    if ($this->sitemap_stylesheet) {
421
      $writer->addProcessingInstructions(
422
423
424
425
426
        'xml-stylesheet',
        'type="text/xsl" href="' . $this->getStylesheetUrl() . '"'
      );
    }

427
428
429
    $this->urlSet->accept($writer);

    return $writer->output();
430
431
432
433
  }

  /**
   * If using a stylesheet, return its absolute URL.
434
   *
435
436
   * @return string
   */
437
  protected function getStylesheetUrl(): string
438
439
440
441
442
443
  {
    if ($this->sitemap_stylesheet_custom
      && filter_var($this->sitemap_stylesheet_custom, FILTER_VALIDATE_URL)) {
      return $this->sitemap_stylesheet_custom;
    }

444
    return $this->urls->httpSiteModules . 'MarkupSitemap/assets/sitemap-stylesheet.xsl';
445
  }
Mike Rockétt's avatar
init  
Mike Rockétt committed
446
}