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

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
10 11
 */

12 13 14
// Require the classloaders
wire('classLoader')->addNamespace('Thepixeldeveloper\Sitemap', __DIR__ . '/src/Sitemap');
wire('classLoader')->addNamespace('Rockett\Concerns', __DIR__ . '/src/Concerns');
Mike Rockétt's avatar
Mike Rockétt committed
15
wire('classLoader')->addNamespace('Rockett\Support', __DIR__ . '/src/Support');
16 17 18 19 20

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
21

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

Mike Rockétt's avatar
Mike Rockétt committed
26 27 28 29
use ProcessWire\WireException;
use ProcessWire\Page;
use ProcessWire\Language;

Mike Rockétt's avatar
init  
Mike Rockétt committed
30 31
class MarkupSitemap extends WireData implements Module
{
32 33 34 35 36 37
  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
38 39 40 41 42

  /**
   * Image fields: each field is mapped to the relavent
   * function for the Image sub-element
   */
43
  private static $imageFields = [
44 45 46
    'Caption' => 'description',
    'License' => 'license',
    'Title' => 'title',
Mike Rockétt's avatar
Mike Rockétt committed
47 48 49
    'GeoLocation' => 'geo|location|geolocation',
  ];

50
  /**
51
   * Sitemap URI
Mike Rockétt's avatar
Mike Rockétt committed
52
   */
53
  const sitemapUri = '/sitemap.xml';
Mike Rockétt's avatar
Mike Rockétt committed
54 55 56

  /**
   * Current request URI
57
   *
Mike Rockétt's avatar
Mike Rockétt committed
58 59 60 61 62
   * @var string
   */
  protected $requestUri = '';

  /**
63
   * Current UrlSet
Mike Rockétt's avatar
Mike Rockétt committed
64
   *
65
   * @var Urlset
Mike Rockétt's avatar
Mike Rockétt committed
66
   */
67
  protected $urlSet;
Mike Rockétt's avatar
Mike Rockétt committed
68 69 70

  /**
   * Module installer
Mike Rockétt's avatar
Mike Rockétt committed
71 72
   * Requires ProcessWire 3.0.16+
   *
Mike Rockétt's avatar
Mike Rockétt committed
73 74 75 76
   * @throws WireException
   */
  public function ___install()
  {
Mike Rockétt's avatar
Mike Rockétt committed
77 78
    if (version_compare($this->config->version, '3.0.16') < 0) {
      throw new WireException("Requires ProcessWire 3.0.16+ to run.");
Mike Rockétt's avatar
init  
Mike Rockétt committed
79
    }
Mike Rockétt's avatar
Mike Rockétt committed
80 81 82 83 84 85 86 87 88 89 90 91 92
  }

  /**
   * 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
93 94 95
   *
   * @var string $valueKey
   * @var mixed $default
Mike Rockétt's avatar
Mike Rockétt committed
96 97 98 99 100 101 102 103
   * @return mixed
   */
  public function getPostedValue($valueKey, $default = false)
  {
    return $this->input->post->$valueKey ?: $default;
  }

  /**
104
   * Initialize the module
Mike Rockétt's avatar
Mike Rockétt committed
105 106 107
   *
   * @return void
   */
108
  public function init(): void
Mike Rockétt's avatar
Mike Rockétt committed
109 110 111 112 113 114 115 116 117 118 119
  {
    // 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
120
        }
Mike Rockétt's avatar
Mike Rockétt committed
121
      }
122

Mike Rockétt's avatar
Mike Rockétt committed
123 124
      // 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
125 126
    }

Mike Rockétt's avatar
Mike Rockétt committed
127 128 129 130
    // 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
131
    }
132

Mike Rockétt's avatar
Mike Rockétt committed
133 134 135 136
    // 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
137
    }
Mike Rockétt's avatar
Mike Rockétt committed
138 139 140
  }

  /**
141
   * Initialize the sitemap render by getting the root URI (giving
Mike Rockétt's avatar
Mike Rockétt committed
142 143 144
   * consideration to multi-site setups) and passing it to the
   * first/parent recursive render-method (addPages).
   *
145 146 147
   * 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
148 149
   *
   * @param HookEvent $event
150
   * @return void
Mike Rockétt's avatar
Mike Rockétt committed
151
   */
152
  public function render(HookEvent $event): void
Mike Rockétt's avatar
Mike Rockétt committed
153 154 155 156 157 158 159 160 161 162
  {
    // 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}";
      }
163 164
    }

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

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

180 181 182 183 184 185
  /**
   * Get cached sitemap markup
   *
   * @param string $rootPage
   * @return string
   */
Mike Rockétt's avatar
Mike Rockétt committed
186
  protected function getCached(string $rootPage): string
187 188 189 190 191 192 193 194
  {
    // Bail out early if debug mode is enabled
    if ($this->config->debug) {
      header('X-Cached-Sitemap: no');
      return $this->buildNewSitemap($rootPage);
    }

    // Cache settings
195
    $cacheTtl = $this->cache_ttl ?: 3600;
196 197 198 199 200
    $cacheKey = 'MarkupSitemap';
    $cacheMethod = $this->cache_method ?: 'MarkupCache';

    // Attempt to fetch sitemap from cache
    $cache = $cacheMethod == 'WireCache' ? $this->cache : $this->modules->MarkupCache;
201
    $output = $cache->get($cacheKey, $cacheTtl);
202 203 204 205 206 207

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

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

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

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

    return $valid;
  }

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

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

  /**
Mike Rockétt's avatar
Mike Rockétt committed
272
   * Add languages to the location entry.
Mike Rockétt's avatar
Mike Rockétt committed
273
   *
274
   * @param Page $page
Mike Rockétt's avatar
Mike Rockétt committed
275
   * @param Url $url
276
   * @return void
277
   */
Mike Rockétt's avatar
Mike Rockétt committed
278
  protected function addLanguages(Page $page, Url $url): void
279 280
  {
    foreach ($this->languages as $altLanguage) {
Mike Rockétt's avatar
Mike Rockétt committed
281 282
      if ($this->pageLanguageInvalid($altLanguage, $page)) continue;
      $languageIsoName = $this->getLanguageIsoName($altLanguage);
283
      $url->addExtension(new Link($languageIsoName, $page->localHttpUrl($altLanguage)));
284 285 286
    }
  }

Mike Rockétt's avatar
Mike Rockétt committed
287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304
  /**
   * Get a language's ISO name
   *
   * @param Language $laguage
   * @return string
   */
  protected function getLanguageIsoName(Language $language): string
  {
    $usesDefaultIso = $language->isDefault()
      && $this->pages->get(1)->name === 'home'
      && !$this->modules->LanguageSupportPageNames->useHomeSegment
      && !empty($this->sitemap_default_iso);

    return $usesDefaultIso
      ? $this->sitemap_default_iso
      : $this->pages->get(1)->localName($language);
  }

305 306
  /**
   * Determine if a page can be included in the sitemap
307
   *
Mike Rockétt's avatar
Mike Rockétt committed
308 309
   * @param Page $page
   * @param array $options
310 311
   * @return bool
   */
Mike Rockétt's avatar
Mike Rockétt committed
312
  public function canBeIncluded(Page $page, ?array $options): bool
313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332
  {
    // 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.
333
   *
Mike Rockétt's avatar
Mike Rockétt committed
334
   * @param Page $page
335
   * @return void
336
   */
Mike Rockétt's avatar
Mike Rockétt committed
337
  protected function addPagesFromRoot(Page $page): void
338 339
  {
    // Get the saved options for this page
Mike Rockétt's avatar
Mike Rockétt committed
340
    $pageSitemapOptions = $this->modules->getConfig($this, "o$page->id");
341 342 343 344 345 346 347 348 349 350 351 352 353 354 355

    // 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.)
356
    if ($page->viewable() && $this->canBeIncluded($page, $pageSitemapOptions)) {
357 358 359 360 361 362 363 364
      // 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;
          }
365

366
          $url = new Url($page->localHttpUrl($language));
Mike Rockétt's avatar
Mike Rockétt committed
367 368
          $url->setLastMod(ParseTimestamp::fromInt($page->modified));
          $this->addLanguages($page, $url);
369

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

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

          $this->urlSet->add($url);
379 380 381 382 383
        }
      } 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);
Mike Rockétt's avatar
Mike Rockétt committed
384
        $url->setLastMod(ParseTimestamp::fromInt($page->modified));
385

386
        if ($pageSitemapOptions['priority']) {
387
          $url->setPriority(ParseFloat::asString($pageSitemapOptions['priority']));
388
        }
389

390 391 392
        if (!$pageSitemapOptions['excludes']['images']) {
          $this->addImages($page, $url);
        }
393 394

        $this->urlSet->add($url);
395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413
      }
    }

    // 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) {
Mike Rockétt's avatar
Mike Rockétt committed
414
          $this->addPagesFromRoot($child);
415 416 417 418 419 420 421
        }
      }
    }
  }

  /**
   * Build a new sitemap (called when cache doesn't have one or we're debugging)
422
   *
Mike Rockétt's avatar
Mike Rockétt committed
423
   * @param string $rootPage
424 425
   * @return string
   */
Mike Rockétt's avatar
Mike Rockétt committed
426
  protected function buildNewSitemap(string $rootPage): string
427 428
  {
    $this->urlSet = new Urlset();
Mike Rockétt's avatar
Mike Rockétt committed
429
    $this->addPagesFromRoot($this->pages->get($rootPage));
430 431 432 433 434
    $writer = new XmlWriterDriver();

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

435
    if ($this->sitemap_stylesheet) {
436
      $writer->addProcessingInstructions(
437 438 439 440 441
        'xml-stylesheet',
        'type="text/xsl" href="' . $this->getStylesheetUrl() . '"'
      );
    }

442 443 444
    $this->urlSet->accept($writer);

    return $writer->output();
445 446 447 448
  }

  /**
   * If using a stylesheet, return its absolute URL.
449
   *
450 451
   * @return string
   */
452
  protected function getStylesheetUrl(): string
453 454 455 456 457 458
  {
    if ($this->sitemap_stylesheet_custom
      && filter_var($this->sitemap_stylesheet_custom, FILTER_VALIDATE_URL)) {
      return $this->sitemap_stylesheet_custom;
    }

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