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

3 4 5 6 7
/**
 * Sitemap for ProcessWire
 *
 * Module class
 *
Mike Rockétt's avatar
Mike Rockétt committed
8
 * @author Mike Rockett <[email protected]>
9
 * @copyright 2017-18
Mike Rockétt's avatar
Mike Rockétt committed
10
 * @license ISC
11 12
 */

Mike Rockétt's avatar
Mike Rockétt committed
13
// Require the classloader
Mike Rockétt's avatar
Mike Rockétt committed
14
require_once __DIR__ . '/ClassLoader.php';
Mike Rockétt's avatar
init  
Mike Rockétt committed
15

Mike Rockétt's avatar
Mike Rockétt committed
16 17 18
use Rockett\Traits\BuilderTrait as BuildsSitemap;
use Rockett\Traits\DebugTrait as Debugs;
use Rockett\Traits\FieldsTrait as BuildsFields;
Mike Rockétt's avatar
init  
Mike Rockétt committed
19 20 21

class MarkupSitemap extends WireData implements Module
{
Mike Rockétt's avatar
Mike Rockétt committed
22 23 24 25 26 27 28
  use BuildsFields, BuildsSitemap, Debugs;

  /**
   * Image fields: each field is mapped to the relavent
   * function for the Image sub-element
   */
  const IMAGE_FIELDS = [
29 30 31
    'Caption' => 'description',
    'License' => 'license',
    'Title' => 'title',
Mike Rockétt's avatar
Mike Rockétt committed
32 33 34 35
    'GeoLocation' => 'geo|location|geolocation',
  ];

  /**
36 37 38 39 40 41 42 43 44 45 46 47 48
   * Default page config array, used for comparison at save-time
   */
  const DEFAULT_PAGE_OPTIONS = [
    'priority' => false,
    'excludes' => [
      'images' => false,
      'page' => false,
      'children' => false,
    ]
  ];

  /**
   * Sßitemap URI
Mike Rockétt's avatar
Mike Rockétt committed
49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77
   */
  const SITEMAP_URI = '/sitemap.xml';

  /**
   * Current request URI
   *
   * @var string
   */
  protected $requestUri = '';

  /**
   * Page selector
   *
   * @reserved
   * @var string
   */
  protected $selector = '';

  /**
   * 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';
    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
78
    }
Mike Rockétt's avatar
Mike Rockétt committed
79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152
  }

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

  /**
   * Commit page sitemap options to module config
   * when page is saved. This is a centralised storage method
   * for all page sitemap options.
   * @param array $options
   */
  public function commitPageSitemapOptions($pageId, $options)
  {
    // Save the options for this page. Previous
    // config is completely discarded.
    return $this->modules->saveConfig($this, "o{$pageId}", [
      'priority' => $options['priority'],
      'excludes' => $options['excludes'],
    ]);
  }

  /**
   * When a page is deleted (note: not trashed), then its
   * sitemap options also need to be deleted. We don't do this
   * when its trashed, just in case the page is restored later.
   * @param  HookEvent $event
   * @return void
   */
  public function deletePageSitemapOptions(HookEvent $event)
  {
    // Get the ID of the page that was deleted
    $pageId = $event->arguments(0)->id;

    // By saving a null value by omission, we're effectively deleting
    // the sitemap options for the deleted page.

    return $this->modules->saveConfig($this, "o{$pageId}");
  }

  /**
   * Return a POSTed value or its default if not available
   * @var    string  $valueKey
   * @var    mixed   $default
   * @return mixed
   */
  public function getPostedValue($valueKey, $default = false)
  {
    return $this->input->post->$valueKey ?: $default;
  }

  /**
   * Initiate the module
   *
   * @return void
   */
  public function init()
  {
    // 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
153
        }
Mike Rockétt's avatar
Mike Rockétt committed
154 155 156
      }
      // 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
157 158
    }

Mike Rockétt's avatar
Mike Rockétt committed
159 160 161 162
    // 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
163
    }
Mike Rockétt's avatar
Mike Rockétt committed
164 165 166 167
    // 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
168
    }
Mike Rockétt's avatar
Mike Rockétt committed
169 170 171 172 173 174 175 176 177 178 179 180
  }

  /**
   * Process Sitemap fields from Settings tab
   * @param  HookEvent $event
   * @return void
   */
  public function processSettingsTab(HookEvent $event)
  {
    // Prevent recursion
    if (($level = $event->arguments(1)) > 0) {
      return;
Mike Rockétt's avatar
init  
Mike Rockétt committed
181 182
    }

Mike Rockétt's avatar
Mike Rockétt committed
183 184 185 186 187
    // Get the current page and stop if we're working
    // with an admin or trashed page.
    $page = $event->object->getPage();
    if ($page->matches("has_parent={$this->config->adminRootPageID}|{$this->config->trashPageID}")) {
      return;
Mike Rockétt's avatar
init  
Mike Rockétt committed
188 189
    }

Mike Rockétt's avatar
Mike Rockétt committed
190 191 192 193 194 195 196 197
    // Build the options instance for this page.
    // If saving the home page, excludes.page and excludes.children
    // are saved as false. The data is kept for the purposes
    // of code simplification, and has no effect on
    // how things work.
    $pageSitemapPageOptions = [
      'priority' => $this->getPostedValue('sitemap_priority'),
      'excludes' => [
198 199
        'images' => $this->getPostedValue('sitemap_exclude_images'),
        'page' => $this->getPostedValue('sitemap_exclude_page'),
Mike Rockétt's avatar
Mike Rockétt committed
200 201 202
        'children' => $this->getPostedValue('sitemap_exclude_children'),
      ],
    ];
Mike Rockétt's avatar
init  
Mike Rockétt committed
203

204 205 206 207 208 209
    $existingOptions = $this->modules->getConfig($this, "o{$page->id}");

    if ($existingOptions === null && $pageSitemapPageOptions === self::DEFAULT_PAGE_OPTIONS) {
      return;
    }

Mike Rockétt's avatar
Mike Rockétt committed
210 211 212
    // Save options for this page
    if (!$this->commitPageSitemapOptions($page->id, $pageSitemapPageOptions)) {
      $this->error($this->_('Something went wrong, and the sitemap options for this page could not be saved.'));
Mike Rockétt's avatar
init  
Mike Rockétt committed
213
    }
Mike Rockétt's avatar
Mike Rockétt committed
214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236
  }

  /**
   * Initiate the sitemap render by getting the root URI (giving
   * consideration to multi-site setups) and passing it to the
   * first/parent recursive render-method (addPages).
   *
   * MarkupCache is used to cache the entire sitemap, and the cache
   * is destroyed when settings are saved and, if set up, a page is saved.
   *
   * @param HookEvent $event
   */
  public function render(HookEvent $event)
  {
    // 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}";
      }
237 238
    }

Mike Rockétt's avatar
Mike Rockétt committed
239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287
    // Make sure that the root page exists.
    if (!$this->pages->get($rootPage) instanceof NullPage) {
      // Check for cached sitemap or regenerate if it doesn't exist
      // $rootPageName = $this->sanitizer->pageName($rootPage);
      $markupCache = $this->modules->MarkupCache;
      if ((!$output = $markupCache->get('MarkupSitemap', 3600)) || $this->config->debug) {
        $output = $this->buildNewSitemap($rootPage);
        $markupCache->save($output);
        header('X-SitemapRetrievedFromCache: no');
      } else {
        header('X-SitemapRetrievedFromCache: yes');
      }
      header('Content-Type: application/xml', true, 200);
      $event->return = $output;

      // Prevent further hooks. This stops
      // SystemNotifications from displaying a 404 event
      // when /sitemap.xml is requested. Additionall,
      // it prevents further modification to the sitemap.
      $event->replace = true;
      $event->cancelHooks = true;
    }
  }

  /**
   * Add sitemap fields to the Settings tab.
   * Responds to ProcessPageEdit::buildFormSettings hook
   * @param  HookEvent $event
   * @return void
   */
  public function setupSettingsTab(HookEvent $event)
  {
    // Get the current page
    $page = $event->object->getPage();

    // We only need to proceed with this process if the current page's
    // template has been assigned as configurable in the module's configuration.
    if ($this->sitemap_include_templates !== null
      && in_array($page->template->name, $this->sitemap_include_templates)
      && !in_array($page->template->name, $this->sitemap_exclude_templates)
    ) {
      // Get the settings tab inputfields
      $inputFields = $event->return;

      // Get the saved options for this page
      $pageOptions = $this->modules->getConfig($this, "o{$page->id}");

      // Sitemap fieldset
      $sitemapFieldset = $this->buildInputField('Fieldset', [
288 289
        'label' => 'Sitemap',
        'icon' => 'sitemap',
Mike Rockétt's avatar
Mike Rockétt committed
290 291 292 293 294
        'collapsed' => Inputfield::collapsedBlank,
      ]);

      // Add priority field
      $sitemapFieldset->append($this->buildInputField('Text', [
295 296
        'name' => 'sitemap_priority',
        'label' => $this->_('Page Priority'),
Mike Rockétt's avatar
Mike Rockétt committed
297
        'description' => $this->_('Set this page’s priority on a scale of 0.0 to 1.0.'),
298
        'notes' => $this->_('This field is optional, and the priority will only be included if it is set here.'),
Mike Rockétt's avatar
Mike Rockétt committed
299
        'columnWidth' => '50%',
300 301
        'pattern' => "(0(\.\d+)?|1(\.0+)?)",
        'value' => $pageOptions['priority'],
Mike Rockétt's avatar
Mike Rockétt committed
302 303 304 305
      ]));

      // Add exclude_images field
      $sitemapFieldset->append($this->buildInputField('Checkbox', [
306 307 308
        'name' => 'sitemap_exclude_images',
        'label' => $this->_('Exclude Images'),
        'label2' => $this->_('Do not add images to the sitemap for this page’s entry'),
Mike Rockétt's avatar
Mike Rockétt committed
309 310
        'description' => $this->_('By default, all image fields for this page will be included in the sitemap. If you don’t want this to happen, you can exclude such inclusion for this page by checking the box below.'),
        'columnWidth' => '50%',
311 312
        'autocheck' => true,
        'value' => $pageOptions['excludes']['images'],
Mike Rockétt's avatar
Mike Rockétt committed
313 314 315 316 317 318
      ]));

      // These fields may only be added to non-root pages.
      if ($page->id !== 1) {
        // Add exclude_page field
        $sitemapFieldset->append($this->buildInputField('Checkbox', [
319 320 321
          'name' => 'sitemap_exclude_page',
          'label' => $this->_('Exclude Page'),
          'label2' => $this->_('Do not include this page in the sitemap'),
Mike Rockétt's avatar
Mike Rockétt committed
322 323
          'description' => $this->_('If you’d like to skip the inclusion of this page (not considering its children, if any) from the sitemap, you can check the box below.'),
          'columnWidth' => '50%',
324 325
          'autocheck' => true,
          'value' => $pageOptions['excludes']['page'],
Mike Rockétt's avatar
Mike Rockétt committed
326 327 328 329
        ]));

        // Add exclude_children field
        $sitemapFieldset->append($this->buildInputField('Checkbox', [
330 331 332
          'name' => 'sitemap_exclude_children',
          'label' => $this->_('Exclude Children'),
          'label2' => $this->_('Do not include this page’s children (if any) in sitemap.xml'),
Mike Rockétt's avatar
Mike Rockétt committed
333 334
          'description' => $this->_('If you’d like to skip the inclusion of this page’s children (if any, and not considering the page itself) from the sitemap, you can check the box below.'),
          'columnWidth' => '50%',
335 336
          'autocheck' => true,
          'value' => $pageOptions['excludes']['children'],
Mike Rockétt's avatar
Mike Rockétt committed
337 338 339 340 341
        ]));
      }

      // Add the new fieldset to the Settings tab (fieldset)
      $inputFields->insertBefore($sitemapFieldset, $inputFields->find('name=status')->first());
342
    }
Mike Rockétt's avatar
Mike Rockétt committed
343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401
  }

  /**
   * Correctly format the priority float to one decimal
   * @param  float
   * @return string
   */
  protected function formatPriorityFloat($priority)
  {
    return sprintf('%.1F', (float) $priority);
  }

  /**
   * Get the root page URI
   * @return string
   */
  protected function getRootPageUri()
  {
    return (string) str_ireplace(
      trim($this->config->urls->root, '/'),
      '',
      $this->sanitizer->path(dirname($this->requestUri))
    );
  }

  /**
   * Determine if the request is valud
   * @return boolean
   */
  protected function isValidRequest()
  {
    $valid = (bool) (
      $this->requestUri !== null &&
      strlen($this->requestUri) - strlen(self::SITEMAP_URI) === strrpos($this->requestUri, self::SITEMAP_URI)
    );

    return $valid;
  }

  /**
   * Check if the language is not default and that the
   * page is not available/statused in the default language.
   * @param  string $language
   * @param  Page   $page
   * @return bool
   */
  protected function pageLanguageInvalid($language, $page)
  {
    return (!$language->isDefault() && !$page->{"status{$language->id}"});
  }

  /**
   * Determine if the site uses the LanguageSupportPageNames module.
   * @return bool
   */
  protected function siteUsesLanguageSupportPageNames()
  {
    return $this->modules->isInstalled('LanguageSupportPageNames');
  }
Mike Rockétt's avatar
init  
Mike Rockétt committed
402
}