MarkupSitemap.module.php 13.7 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
    use BuildsFields, BuildsSitemap, Debugs;
23

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

    /**
     * Sitemap URI
     */
    const SITEMAP_URI = '/sitemap.xml';

    /**
     * Current request URI
42
     *
Mike Rockétt's avatar
init  
Mike Rockétt committed
43 44 45 46 47 48
     * @var string
     */
    protected $requestUri = '';

    /**
     * Page selector
49
     *
Mike Rockétt's avatar
Mike Rockétt committed
50
     * @reserved
Mike Rockétt's avatar
init  
Mike Rockétt committed
51 52 53 54
     * @var string
     */
    protected $selector = '';

Mike Rockétt's avatar
Mike Rockétt committed
55 56
    /**
     * Module installer
Mike Rockétt's avatar
Mike Rockétt committed
57
     * Requires ProcessWire 2.8.16+/3.0.16+ (saveConfig; getConfig)
Mike Rockétt's avatar
Mike Rockétt committed
58 59 60 61
     * @throws WireException
     */
    public function ___install()
    {
Mike Rockétt's avatar
Mike Rockétt committed
62 63 64 65
        $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
Mike Rockétt committed
66 67
        }
    }
Mike Rockétt's avatar
init  
Mike Rockétt committed
68 69

    /**
Mike Rockétt's avatar
Mike Rockétt committed
70 71
     * Class constructor
     * Get and assign the current request URI
Mike Rockétt's avatar
init  
Mike Rockétt committed
72
     */
Mike Rockétt's avatar
Mike Rockétt committed
73 74
    public function __construct()
    {
Mike Rockétt's avatar
Mike Rockétt committed
75
        // Set the request URI
Mike Rockétt's avatar
Mike Rockétt committed
76 77
        $this->requestUri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : null;
    }
Mike Rockétt's avatar
init  
Mike Rockétt committed
78 79

    /**
Mike Rockétt's avatar
Mike Rockétt committed
80 81 82 83
     * 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
Mike Rockétt's avatar
init  
Mike Rockétt committed
84
     */
Mike Rockétt's avatar
Mike Rockétt committed
85
    public function commitPageSitemapOptions($pageId, $options)
Mike Rockétt's avatar
init  
Mike Rockétt committed
86
    {
Mike Rockétt's avatar
Mike Rockétt committed
87 88 89 90 91 92
        // Save the options for this page. Previous
        // config is completely discarded.
        return $this->modules->saveConfig($this, "o{$pageId}", [
            'priority' => $options['priority'],
            'excludes' => $options['excludes'],
        ]);
Mike Rockétt's avatar
init  
Mike Rockétt committed
93 94 95
    }

    /**
Mike Rockétt's avatar
Mike Rockétt committed
96 97 98 99
     * 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
Mike Rockétt's avatar
init  
Mike Rockétt committed
100 101
     * @return void
     */
Mike Rockétt's avatar
Mike Rockétt committed
102
    public function deletePageSitemapOptions(HookEvent $event)
Mike Rockétt's avatar
init  
Mike Rockétt committed
103
    {
Mike Rockétt's avatar
Mike Rockétt committed
104 105 106 107 108 109
        // 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}");
Mike Rockétt's avatar
init  
Mike Rockétt committed
110 111 112
    }

    /**
Mike Rockétt's avatar
Mike Rockétt committed
113 114 115 116
     * Return a POSTed value or its default if not available
     * @var    string  $valueKey
     * @var    mixed   $default
     * @return mixed
Mike Rockétt's avatar
init  
Mike Rockétt committed
117
     */
Mike Rockétt's avatar
Mike Rockétt committed
118
    public function getPostedValue($valueKey, $default = false)
Mike Rockétt's avatar
init  
Mike Rockétt committed
119
    {
Mike Rockétt's avatar
Mike Rockétt committed
120
        return $this->input->post->$valueKey ?: $default;
Mike Rockétt's avatar
init  
Mike Rockétt committed
121 122 123 124
    }

    /**
     * Initiate the module
125
     *
Mike Rockétt's avatar
init  
Mike Rockétt committed
126 127 128 129 130 131 132 133
     * @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...)
134
            if ($this->siteUsesLanguageSupportPageNames()) {
Mike Rockétt's avatar
init  
Mike Rockétt committed
135 136 137
                foreach (['localHttpUrl', 'localName'] as $pageHook) {
                    $pageHookFunction = 'hookPage' . ucfirst($pageHook);
                    $this->addHook("Page::{$pageHook}", null, function ($event) use ($pageHookFunction) {
138
                        $this->modules->LanguageSupportPageNames->{$pageHookFunction}($event);
Mike Rockétt's avatar
init  
Mike Rockétt committed
139 140 141 142
                    });
                }
            }
            // Add the hook to process and render the sitemap.
Mike Rockétt's avatar
Mike Rockétt committed
143
            $this->addHookBefore('ProcessPageView::pageNotFound', $this, 'render');
Mike Rockétt's avatar
init  
Mike Rockétt committed
144
        }
Mike Rockétt's avatar
Mike Rockétt committed
145 146 147 148 149 150 151 152 153 154 155

        // 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');
        }
        // 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 157 158
    }

    /**
Mike Rockétt's avatar
Mike Rockétt committed
159 160 161
     * Process Sitemap fields from Settings tab
     * @param  HookEvent $event
     * @return void
Mike Rockétt's avatar
init  
Mike Rockétt committed
162
     */
Mike Rockétt's avatar
Mike Rockétt committed
163
    public function processSettingsTab(HookEvent $event)
Mike Rockétt's avatar
init  
Mike Rockétt committed
164
    {
Mike Rockétt's avatar
Mike Rockétt committed
165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193
        // Prevent recursion
        if (($level = $event->arguments(1)) > 0) {
            return;
        }

        // 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;
        }

        // 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'),
            ],
        ];

        // 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
194 195 196 197
        }
    }

    /**
198 199 200 201 202 203 204
     * 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.
     *
Mike Rockétt's avatar
init  
Mike Rockétt committed
205 206 207 208 209 210 211 212
     * @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.
213 214
        if ($this->modules->isInstalled('MultiSite')) {
            $multiSite = $this->modules->get('MultiSite');
Mike Rockétt's avatar
init  
Mike Rockétt committed
215 216 217 218 219 220
            if ($multiSite->subdomain) {
                $rootPage = "/{$multiSite->subdomain}{$rootPage}";
            }
        }

        // Make sure that the root page exists.
Mike Rockétt's avatar
Mike Rockétt committed
221 222
        if (!$this->pages->get($rootPage) instanceof NullPage) {
            // Check for cached sitemap or regenerate if it doesn't exist
Mike Rockétt's avatar
Mike Rockétt committed
223
            // $rootPageName = $this->sanitizer->pageName($rootPage);
Mike Rockétt's avatar
Mike Rockétt committed
224 225
            $markupCache = $this->modules->MarkupCache;
            if ((!$output = $markupCache->get('MarkupSitemap', 3600)) || $this->config->debug) {
Mike Rockétt's avatar
Mike Rockétt committed
226
                $output = $this->buildNewSitemap($rootPage);
Mike Rockétt's avatar
Mike Rockétt committed
227
                $markupCache->save($output);
Mike Rockétt's avatar
Mike Rockétt committed
228
                header('X-SitemapRetrievedFromCache: no');
Mike Rockétt's avatar
Mike Rockétt committed
229 230
            } else {
                header('X-SitemapRetrievedFromCache: yes');
231
            }
Mike Rockétt's avatar
Mike Rockétt committed
232 233 234 235 236 237 238 239 240
            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;
Mike Rockétt's avatar
init  
Mike Rockétt committed
241 242 243 244
        }
    }

    /**
Mike Rockétt's avatar
Mike Rockétt committed
245 246 247 248
     * Add sitemap fields to the Settings tab.
     * Responds to ProcessPageEdit::buildFormSettings hook
     * @param  HookEvent $event
     * @return void
249
     */
Mike Rockétt's avatar
Mike Rockétt committed
250
    public function setupSettingsTab(HookEvent $event)
251
    {
Mike Rockétt's avatar
Mike Rockétt committed
252 253 254 255 256 257
        // 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
258 259 260
            && in_array($page->template->name, $this->sitemap_include_templates)
            && !in_array($page->template->name, $this->sitemap_exclude_templates)
        ) {
Mike Rockétt's avatar
Mike Rockétt committed
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
            // 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'],
                ]));
319
            }
Mike Rockétt's avatar
init  
Mike Rockétt committed
320

Mike Rockétt's avatar
Mike Rockétt committed
321 322
            // Add the new fieldset to the Settings tab (fieldset)
            $inputFields->insertBefore($sitemapFieldset, $inputFields->find('name=status')->first());
Mike Rockétt's avatar
init  
Mike Rockétt committed
323 324 325 326
        }
    }

    /**
327 328 329
     * Correctly format the priority float to one decimal
     * @param  float
     * @return string
Mike Rockétt's avatar
init  
Mike Rockétt committed
330
     */
331
    protected function formatPriorityFloat($priority)
Mike Rockétt's avatar
init  
Mike Rockétt committed
332
    {
333
        return sprintf('%.1F', (float) $priority);
Mike Rockétt's avatar
init  
Mike Rockétt committed
334 335 336 337 338 339 340 341
    }

    /**
     * Get the root page URI
     * @return string
     */
    protected function getRootPageUri()
    {
Mike Rockétt's avatar
Mike Rockétt committed
342 343 344 345 346
        return (string) str_ireplace(
            trim($this->config->urls->root, '/'),
            '',
            $this->sanitizer->path(dirname($this->requestUri))
        );
Mike Rockétt's avatar
init  
Mike Rockétt committed
347 348 349 350 351 352 353 354 355 356 357 358 359 360 361
    }

    /**
     * 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;
    }
362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382

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