MarkupSitemap.module.php 16.3 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 <[email protected]>
 * @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
use ProcessWire\Language;
use ProcessWire\Page;
use ProcessWire\WireException;
20 21
use Rockett\Concerns;
use Rockett\Support\ParseFloat;
Mike Rockétt's avatar
Mike Rockétt committed
22
use Rockett\Support\ParseTimestamp;
23 24 25 26
use Thepixeldeveloper\Sitemap\Drivers\XmlWriterDriver;
use Thepixeldeveloper\Sitemap\Extensions\Link;
use Thepixeldeveloper\Sitemap\Url;
use Thepixeldeveloper\Sitemap\Urlset;
Mike Rockétt's avatar
Mike Rockétt committed
27

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

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

48
  /**
49
   * Sitemap URI
Mike Rockétt's avatar
Mike Rockétt committed
50
   */
51
  const sitemapUri = '/sitemap.xml';
Mike Rockétt's avatar
Mike Rockétt committed
52

53 54 55 56 57 58 59 60 61 62 63 64
  /**
   * The name of the additional pages hook
   */
  const getAdditionalPages = 'MarkupSitemap::getAdditionalPages';

  /**
   * Determine whether language support hooks have been added.
   *
   * @var bool
   */
  private static $languageSupportHooksApplied;

Mike Rockétt's avatar
Mike Rockétt committed
65 66
  /**
   * Current request URI
67
   *
Mike Rockétt's avatar
Mike Rockétt committed
68 69 70 71 72
   * @var string
   */
  protected $requestUri = '';

  /**
73
   * Current UrlSet
Mike Rockétt's avatar
Mike Rockétt committed
74
   *
75
   * @var Urlset
Mike Rockétt's avatar
Mike Rockétt committed
76
   */
77
  protected $urlSet;
Mike Rockétt's avatar
Mike Rockétt committed
78 79 80

  /**
   * Module installer
Mike Rockétt's avatar
Mike Rockétt committed
81 82
   * Requires ProcessWire 3.0.16+
   *
Mike Rockétt's avatar
Mike Rockétt committed
83 84 85 86
   * @throws WireException
   */
  public function ___install()
  {
Mike Rockétt's avatar
Mike Rockétt committed
87 88
    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
89
    }
Mike Rockétt's avatar
Mike Rockétt committed
90 91 92 93 94 95 96 97 98 99 100 101 102
  }

  /**
   * 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
103 104 105
   *
   * @var string $valueKey
   * @var mixed $default
Mike Rockétt's avatar
Mike Rockétt committed
106 107 108 109 110 111 112 113
   * @return mixed
   */
  public function getPostedValue($valueKey, $default = false)
  {
    return $this->input->post->$valueKey ?: $default;
  }

  /**
114
   * Initialize the module
Mike Rockétt's avatar
Mike Rockétt committed
115 116 117
   *
   * @return void
   */
118
  public function init(): void
Mike Rockétt's avatar
Mike Rockétt committed
119 120 121 122 123 124
  {
    // 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()) {
125
        static::applyLanguageSupportHooks();
Mike Rockétt's avatar
Mike Rockétt committed
126
      }
127

Mike Rockétt's avatar
Mike Rockétt committed
128
      // Add the hook to process and render the sitemap.
129
      $this->addHookAfter('ProcessPageView::pageNotFound', $this, 'render');
Mike Rockétt's avatar
init  
Mike Rockétt committed
130 131
    }

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

Mike Rockétt's avatar
Mike Rockétt committed
138 139 140 141
    // 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
142
    }
Mike Rockétt's avatar
Mike Rockétt committed
143 144
  }

145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162
  /**
   * Add the relevant page hooks for multi-language support
   *
   * @return void
   */
  public static function applyLanguageSupportHooks(): void
  {
    if (!static::$languageSupportHooksApplied) {
      foreach (['localUrl', 'localHttpUrl', 'localName'] as $pageHook) {
        $pageHookFunction = 'hookPage' . ucfirst($pageHook);
        wire()->addHook("Page::{$pageHook}", null, function ($event) use ($pageHookFunction) {
          wire('modules')->LanguageSupportPageNames->{$pageHookFunction}($event);
        });
      }
      static::$languageSupportHooksApplied = true;
    }
  }

Mike Rockétt's avatar
Mike Rockétt committed
163
  /**
164
   * Initialize the sitemap render by getting the root URI (giving
Mike Rockétt's avatar
Mike Rockétt committed
165 166 167
   * consideration to multi-site setups) and passing it to the
   * first/parent recursive render-method (addPages).
   *
168 169 170
   * 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
171 172
   *
   * @param HookEvent $event
173
   * @return void
Mike Rockétt's avatar
Mike Rockétt committed
174
   */
175
  public function render(HookEvent $event): void
Mike Rockétt's avatar
Mike Rockétt committed
176 177 178 179 180 181 182 183 184 185
  {
    // 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}";
      }
186 187
    }

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

Mike Rockétt's avatar
Mike Rockétt committed
194 195
      // Prevent further hooks. This stops
      // SystemNotifications from displaying a 404 event
196
      // when /sitemap.xml is requested. Additionally,
Mike Rockétt's avatar
Mike Rockétt committed
197 198 199 200 201 202
      // it prevents further modification to the sitemap.
      $event->replace = true;
      $event->cancelHooks = true;
    }
  }

203 204 205 206 207 208
  /**
   * Get cached sitemap markup
   *
   * @param string $rootPage
   * @return string
   */
Mike Rockétt's avatar
Mike Rockétt committed
209
  protected function getCached(string $rootPage): string
210 211 212 213 214 215 216 217
  {
    // Bail out early if debug mode is enabled
    if ($this->config->debug) {
      header('X-Cached-Sitemap: no');
      return $this->buildNewSitemap($rootPage);
    }

    // Cache settings
218
    $cacheTtl = $this->cache_ttl ?: 3600;
219 220 221 222 223
    $cacheKey = 'MarkupSitemap';
    $cacheMethod = $this->cache_method ?: 'MarkupCache';

    // Attempt to fetch sitemap from cache
    $cache = $cacheMethod == 'WireCache' ? $this->cache : $this->modules->MarkupCache;
224
    $output = $cache->get($cacheKey, $cacheTtl);
225 226 227 228 229 230

    // If output is empty, generate and cache new sitemap
    if (empty($output)) {
      header('X-Cached-Sitemap: no');
      $output = $this->buildNewSitemap($rootPage);
      if ($cacheMethod == 'WireCache') {
231
        $cache->save($cacheKey, $output, $cacheTtl);
232 233 234 235 236 237 238 239 240 241
      } else {
        $cache->save($output);
      }
      return $output;
    }

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

Mike Rockétt's avatar
Mike Rockétt committed
242 243
  /**
   * Get the root page URI
244
   *
Mike Rockétt's avatar
Mike Rockétt committed
245 246
   * @return string
   */
247
  protected function getRootPageUri(): string
Mike Rockétt's avatar
Mike Rockétt committed
248 249 250 251 252 253 254 255 256 257
  {
    return (string) str_ireplace(
      trim($this->config->urls->root, '/'),
      '',
      $this->sanitizer->path(dirname($this->requestUri))
    );
  }

  /**
   * Determine if the request is valud
258
   *
Mike Rockétt's avatar
Mike Rockétt committed
259 260
   * @return boolean
   */
261
  protected function isValidRequest(): bool
Mike Rockétt's avatar
Mike Rockétt committed
262 263 264
  {
    $valid = (bool) (
      $this->requestUri !== null &&
265
      strlen($this->requestUri) - strlen(self::sitemapUri) === strrpos($this->requestUri, self::sitemapUri)
Mike Rockétt's avatar
Mike Rockétt committed
266 267 268 269 270 271 272 273
    );

    return $valid;
  }

  /**
   * Check if the language is not default and that the
   * page is not available/statused in the default language.
274
   *
Mike Rockétt's avatar
Mike Rockétt committed
275 276
   * @param Language $language
   * @param Page $page
Mike Rockétt's avatar
Mike Rockétt committed
277 278
   * @return bool
   */
Mike Rockétt's avatar
Mike Rockétt committed
279
  protected function pageLanguageInvalid(Language $language, Page $page): bool
Mike Rockétt's avatar
Mike Rockétt committed
280 281 282 283 284 285
  {
    return (!$language->isDefault() && !$page->{"status{$language->id}"});
  }

  /**
   * Determine if the site uses the LanguageSupportPageNames module.
286
   *
Mike Rockétt's avatar
Mike Rockétt committed
287 288
   * @return bool
   */
289
  protected function siteUsesLanguageSupportPageNames(): bool
Mike Rockétt's avatar
Mike Rockétt committed
290 291 292
  {
    return $this->modules->isInstalled('LanguageSupportPageNames');
  }
293 294

  /**
Mike Rockétt's avatar
Mike Rockétt committed
295
   * Add languages to the location entry.
Mike Rockétt's avatar
Mike Rockétt committed
296
   *
297
   * @param Page $page
Mike Rockétt's avatar
Mike Rockétt committed
298
   * @param Url $url
299
   * @return void
300
   */
Mike Rockétt's avatar
Mike Rockétt committed
301
  protected function addLanguages(Page $page, Url $url): void
302 303
  {
    foreach ($this->languages as $altLanguage) {
304 305 306 307
      if ($this->pageLanguageInvalid($altLanguage, $page)) {
        continue;
      }

Mike Rockétt's avatar
Mike Rockétt committed
308
      $languageIsoName = $this->getLanguageIsoName($altLanguage);
309
      $url->addExtension(new Link($languageIsoName, $page->localHttpUrl($altLanguage)));
310 311 312
    }
  }

Mike Rockétt's avatar
Mike Rockétt committed
313 314 315 316 317 318 319 320 321
  /**
   * Get a language's ISO name
   *
   * @param Language $laguage
   * @return string
   */
  protected function getLanguageIsoName(Language $language): string
  {
    $usesDefaultIso = $language->isDefault()
322 323 324
    && $this->pages->get(1)->name === 'home'
    && !$this->modules->LanguageSupportPageNames->useHomeSegment
    && !empty($this->sitemap_default_iso);
Mike Rockétt's avatar
Mike Rockétt committed
325 326

    return $usesDefaultIso
327 328
    ? $this->sitemap_default_iso
    : $this->pages->get(1)->localName($language);
Mike Rockétt's avatar
Mike Rockétt committed
329 330
  }

331 332
  /**
   * Determine if a page can be included in the sitemap
333
   *
Mike Rockétt's avatar
Mike Rockétt committed
334 335
   * @param Page $page
   * @param array $options
336 337
   * @return bool
   */
Mike Rockétt's avatar
Mike Rockétt committed
338
  public function canBeIncluded(Page $page, ?array $options): bool
339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358
  {
    // 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.
359
   *
Mike Rockétt's avatar
Mike Rockétt committed
360
   * @param Page $page
361
   * @return void
362
   */
Mike Rockétt's avatar
Mike Rockétt committed
363
  protected function addPagesFromRoot(Page $page): void
364 365
  {
    // Get the saved options for this page
Mike Rockétt's avatar
Mike Rockétt committed
366
    $pageSitemapOptions = $this->modules->getConfig($this, "o$page->id");
367 368 369 370 371 372 373 374 375 376 377 378 379 380 381

    // 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.)
382
    if ($page->viewable() && $this->canBeIncluded($page, $pageSitemapOptions)) {
383 384 385 386 387 388 389 390
      // 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;
          }
391

392
          $url = new Url($page->localHttpUrl($language));
Mike Rockétt's avatar
Mike Rockétt committed
393 394
          $url->setLastMod(ParseTimestamp::fromInt($page->modified));
          $this->addLanguages($page, $url);
395

396
          if ($pageSitemapOptions['priority']) {
397
            $url->setPriority(ParseFloat::asString($pageSitemapOptions['priority']));
398
          }
399

400 401 402
          if (!$pageSitemapOptions['excludes']['images']) {
            $this->addImages($page, $url, $language);
          }
403 404

          $this->urlSet->add($url);
405
          $this->addAdditionalPages($page, $language);
406 407 408 409 410
        }
      } 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
411
        $url->setLastMod(ParseTimestamp::fromInt($page->modified));
412

413
        if ($pageSitemapOptions['priority']) {
414
          $url->setPriority(ParseFloat::asString($pageSitemapOptions['priority']));
415
        }
416

417 418 419
        if (!$pageSitemapOptions['excludes']['images']) {
          $this->addImages($page, $url);
        }
420 421

        $this->urlSet->add($url);
422
        $this->addAdditionalPages($page);
423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441
      }
    }

    // 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
442
          $this->addPagesFromRoot($child);
443 444 445 446 447
        }
      }
    }
  }

448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498
  /**
   * Add additional pages supplied via the getAdditionalPages() hook
   *
   * @param Page $page
   * @param Language $language
   * @return void
   */
  protected function addAdditionalPages(Page $page, Language $language = null): void
  {
    $additionalPages = $this->getAdditionalPages($page, $language);

    // Process each page from the data provided in the hook
    foreach ($additionalPages as $key => $item) {
      if (!$item['url']) {
        continue;
      }

      $url = new Url($item['url']);
      $modified = isset($item['modified']) ? $item['modified'] : $page->modified;

      $url->setLastMod(ParseTimestamp::fromInt($modified));

      if (isset($item['priority'])) {
        $url->setPriority(ParseFloat::asString($item['priority']));
      }

      // If language support is enabled, then we need to loop through each language
      // and add the alternate URLs of each additional page
      if ($this->siteUsesLanguageSupportPageNames()) {
        foreach ($this->languages as $language) {
          // Generate the additional URLs in the alternate language
          // and check if the same item is found within the alternate language URLs
          $urlsInLanguage = $this->getAdditionalPages($page, $language);

          if (isset($urlsInLanguage[$key])) {
            $languageItem = $urlsInLanguage[$key];
            if (!$languageItem['url']) {
              continue;
            }

            // Add the alternate language URL
            $languageIsoName = $this->getLanguageIsoName($language);
            $url->addExtension(new Link($languageIsoName, $languageItem['url']));
          }
        }
      }

      $this->urlSet->add($url);
    }
  }

499 500
  /**
   * Build a new sitemap (called when cache doesn't have one or we're debugging)
501
   *
Mike Rockétt's avatar
Mike Rockétt committed
502
   * @param string $rootPage
503 504
   * @return string
   */
Mike Rockétt's avatar
Mike Rockétt committed
505
  protected function buildNewSitemap(string $rootPage): string
506 507
  {
    $this->urlSet = new Urlset();
Mike Rockétt's avatar
Mike Rockétt committed
508
    $this->addPagesFromRoot($this->pages->get($rootPage));
509 510 511 512 513
    $writer = new XmlWriterDriver();

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

514
    if ($this->sitemap_stylesheet) {
515
      $writer->addProcessingInstructions(
516 517 518 519 520
        'xml-stylesheet',
        'type="text/xsl" href="' . $this->getStylesheetUrl() . '"'
      );
    }

521 522 523
    $this->urlSet->accept($writer);

    return $writer->output();
524 525 526 527
  }

  /**
   * If using a stylesheet, return its absolute URL.
528
   *
529 530
   * @return string
   */
531
  protected function getStylesheetUrl(): string
532 533 534 535 536 537
  {
    if ($this->sitemap_stylesheet_custom
      && filter_var($this->sitemap_stylesheet_custom, FILTER_VALIDATE_URL)) {
      return $this->sitemap_stylesheet_custom;
    }

538
    return $this->urls->httpSiteModules . 'MarkupSitemap/assets/sitemap-stylesheet.xsl';
539
  }
540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570

  /**
   * This hook adds support for pages that do not exist in the Page Tree,
   * such as those build behind a URL segment.
   *
   * It receives the actual parent Page as well as the Language, in the case
   * of a multi-language setup. The return value must b an array of
   * additional URL objects, containing the following three keys:
   *
   * `url` string, required
   * `modified` int, optional
   * `priority` float|string, optional
   *
   * To associate additional pages with their alternate-language variants, make sure
   * to add unique keys to the result array. Ex: an index or a language-independent ID.
   *
   * @param Page $page
   * @param Language $language
   * @return array
   */
  protected function ___getAdditionalPages(Page $page, Language $language = null): array
  {
    $return = [];

    if ($this->siteUsesLanguageSupportPageNames()) {
      static::applyLanguageSupportHooks();
    }

    return $return;
  }

Mike Rockétt's avatar
init  
Mike Rockétt committed
571
}