MarkupSitemap.module.php 12.5 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
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 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65
  use BuildsFields, BuildsSitemap, Debugs;

  /**
   * Image fields: each field is mapped to the relavent
   * function for the Image sub-element
   */
  const IMAGE_FIELDS = [
    'Caption'     => 'description',
    'License'     => 'license',
    'Title'       => 'title',
    'GeoLocation' => 'geo|location|geolocation',
  ];

  /**
   * Sitemap URI
   */
  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
66
    }
Mike Rockétt's avatar
Mike Rockétt committed
67 68 69 70 71 72 73 74 75 76 77 78 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
  }

  /**
   * 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
141
        }
Mike Rockétt's avatar
Mike Rockétt committed
142 143 144
      }
      // 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
145 146
    }

Mike Rockétt's avatar
Mike Rockétt committed
147 148 149 150
    // 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
151
    }
Mike Rockétt's avatar
Mike Rockétt committed
152 153 154 155
    // 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
156
    }
Mike Rockétt's avatar
Mike Rockétt committed
157 158 159 160 161 162 163 164 165 166 167 168
  }

  /**
   * 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
169 170
    }

Mike Rockétt's avatar
Mike Rockétt committed
171 172 173 174 175
    // 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
176 177
    }

Mike Rockétt's avatar
Mike Rockétt committed
178 179 180 181 182 183 184 185 186 187 188 189 190
    // 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' => [
        'images'   => $this->getPostedValue('sitemap_exclude_images'),
        'page'     => $this->getPostedValue('sitemap_exclude_page'),
        'children' => $this->getPostedValue('sitemap_exclude_children'),
      ],
    ];
Mike Rockétt's avatar
init  
Mike Rockétt committed
191

Mike Rockétt's avatar
Mike Rockétt committed
192 193 194
    // 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
195
    }
Mike Rockétt's avatar
Mike Rockétt committed
196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218
  }

  /**
   * 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}";
      }
219 220
    }

Mike Rockétt's avatar
Mike Rockétt committed
221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 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 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323
    // 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', [
        'label'     => 'Sitemap',
        'icon'      => 'sitemap',
        'collapsed' => Inputfield::collapsedBlank,
      ]);

      // Add priority field
      $sitemapFieldset->append($this->buildInputField('Text', [
        'name'        => 'sitemap_priority',
        'label'       => $this->_('Page Priority'),
        'description' => $this->_('Set this page’s priority on a scale of 0.0 to 1.0.'),
        'notes'       => $this->_('This field is optional, and the priority will only be included if it is set here.'),
        'columnWidth' => '50%',
        'pattern'     => "(0(\.\d+)?|1(\.0+)?)",
        'value'       => $pageOptions['priority'],
      ]));

      // Add exclude_images field
      $sitemapFieldset->append($this->buildInputField('Checkbox', [
        'name'        => 'sitemap_exclude_images',
        'label'       => $this->_('Exclude Images'),
        'label2'      => $this->_('Do not add images to the sitemap for this page’s entry'),
        '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%',
        'autocheck'   => true,
        'value'       => $pageOptions['excludes']['images'],
      ]));

      // These fields may only be added to non-root pages.
      if ($page->id !== 1) {
        // Add exclude_page field
        $sitemapFieldset->append($this->buildInputField('Checkbox', [
          'name'        => 'sitemap_exclude_page',
          'label'       => $this->_('Exclude Page'),
          'label2'      => $this->_('Do not include this page in the sitemap'),
          '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%',
          'autocheck'   => true,
          'value'       => $pageOptions['excludes']['page'],
        ]));

        // Add exclude_children field
        $sitemapFieldset->append($this->buildInputField('Checkbox', [
          'name'        => 'sitemap_exclude_children',
          'label'       => $this->_('Exclude Children'),
          'label2'      => $this->_('Do not include this page’s children (if any) in sitemap.xml'),
          '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%',
          'autocheck'   => true,
          'value'       => $pageOptions['excludes']['children'],
        ]));
      }

      // Add the new fieldset to the Settings tab (fieldset)
      $inputFields->insertBefore($sitemapFieldset, $inputFields->find('name=status')->first());
324
    }
Mike Rockétt's avatar
Mike Rockétt committed
325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 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
  }

  /**
   * 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
384
}