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 <[email protected]>
 * @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
}