artabro/wire/modules/PageRender.module
2024-08-27 11:35:37 +02:00

839 lines
26 KiB
Text
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php namespace ProcessWire;
/**
* ProcessWire PageRender Module
*
* Adds a render method to Page, as used by the PageView Process.
* This module is also able to cache page renders.
* It hooks into Pages and Fieldtypes to ensure cache files are cleaned/deleted when pages are saved/deleted.
*
* ProcessWire 3.x, Copyright 2021 by Ryan Cramer
* https://processwire.com
*
* @method renderPage(HookEvent $event)
* @method void clearCacheFileAll(Page $page)
* @method void clearCacheFilePages(PageArray $items, Page $page)
* @method string saveCacheFileReady(Page $page, $data)
*
*/
class PageRender extends WireData implements Module, ConfigurableModule {
const cacheDirName = 'Page';
public static function getModuleInfo() {
return array(
'title' => __('Page Render', __FILE__), // Module Title
'summary' => __('Adds a render method to Page and caches page output.', __FILE__), // Module Summary
'version' => 105,
'permanent' => true,
'singular' => true,
'autoload' => true,
);
}
/**
* Instance of Config, cached wire('config')
*
*/
protected $config;
/**
* Stack of pages when rendering recursively
*
*/
protected $pageStack = array();
/**
* Keeps track of recursion level when rendering recursively
*
* Used to determine when pageStack should be maintained
*
*/
protected $renderRecursionLevel = 0;
/**
* Page that get() method should pull properties from for rendering fields
*
* Note: every get() call sets this back to NULL after it has executed.
*
* @var null|Page
*
*/
protected $propertyPage = null;
/**
* Initialize the hooks
*
*/
public function init() {
$this->useFuel(false);
$this->config = $this->wire()->config;
$this->addHook('Page::render', $this, 'renderPage');
$pages = $this->wire()->pages;
$pages->addHookAfter('save', $this, 'clearCacheFile');
$pages->addHookAfter('delete', $this, 'clearCacheFile');
// $this->addHookAfter('Fieldtype::savePageField', $this, 'savePageField'); // removed, see note in commented function
}
/**
* API ready
*
*/
public function ready() {
$this->addHookBefore('Page::render', $this, 'beforeRenderPage', array('priority' => 1));
}
/**
* Set page for get() properties / field rendering
*
* @param Page $page
*
*/
public function setPropertyPage(Page $page) {
$this->propertyPage = $page;
}
/**
* Handle page field renders like $page->render->title
*
* @param string $key
* @return string|mixed
*
*/
public function __get($key) {
if(!$this->propertyPage) return parent::__get($key);
$out = $this->propertyPage->renderField($key);
$this->propertyPage = null;
return $out;
}
/**
* Is the page render cache allowed for this request?
*
* @param Page $page
* @return bool
*
*/
public function isCacheAllowed($page) {
$template = $page->template;
if(!$template || ((int) $template->cache_time) < 1) return false;
if(!$this->wire()->user->isGuest()) {
if(!$template->useCacheForUsers) return false;
if($page->editable()) return false;
}
$allowed = true;
$noCacheGetVars = $template->noCacheGetVars;
$noCachePostVars = $template->noCachePostVars;
if($noCacheGetVars && count($_GET)) {
if(strpos($noCacheGetVars, '*') !== false) {
$allowed = false;
} else {
$vars = explode(' ', $noCacheGetVars);
foreach($vars as $name) if($name && isset($_GET[$name])) $allowed = false;
}
}
if($allowed && $noCachePostVars && count($_POST)) {
if(strpos($noCachePostVars, '*') !== false) {
$allowed = false;
} else {
$vars = explode(' ', $noCachePostVars);
foreach($vars as $name) if($name && isset($_POST[$name])) $allowed = false;
}
}
if($allowed) {
// NOTE: other modules may set a session var of PageRenderNoCachePage containing a page ID to temporarily
// remove caching for some page, if necessary.
$id = (int) $this->wire()->session->get('PageRenderNoCachePage');
if($id && $id === $page->id) $allowed = false;
}
return $allowed;
}
/**
* Get a CacheFile object corresponding to this Page
*
* Note that this does not check if the page is cachable. This is so that if a cachable setting changes the cache can still be removed.
*
* @param int|Page $page May provide page id (int) only if using for deleting a cache file. Must provide Page object otherwise.
* @param array $options
* @return CacheFile
* @throws WireException
*
*/
public function getCacheFile($page, array $options = array()) {
$config = $this->config;
$defaults = array(
'prependFile' => '',
'appendFile' => '',
'filename' => '',
);
$options = array_merge($defaults, $options);
$path = $config->paths->cache . self::cacheDirName . "/";
if(is_object($page)) {
$id = $page->id;
$cacheTime = (int) $page->template->cache_time;
} else {
$id = (int) $page;
$cacheTime = 3600;
}
if(!is_dir($path)) {
if(!$this->wire()->files->mkdir($path, true)) throw new WireException("Cache path does not exist: $path");
}
$cacheFile = new CacheFile($path, $id, $cacheTime);
$this->wire($cacheFile);
if($this->wire()->page === $page) {
// this part is skipped if arguments provided an id (int) rather than a Page object
$secondaryID = '';
$input = $this->wire()->input;
$sanitizer = $this->wire()->sanitizer;
$pageNum = $input->pageNum;
$urlSegments = $input->urlSegments;
if(count($urlSegments)) {
foreach($urlSegments as $urlSegment) {
$secondaryID .= $sanitizer->pageName($urlSegment) . '+';
}
}
if($options['prependFile'] || $options['appendFile'] || $options['filename']) {
$secondaryID .= md5($options['prependFile'] . '+' . $options['appendFile'] . '+' . $options['filename']) . '+';
}
if($config->ajax) $secondaryID .= 'ajax+'; // #1262
if($config->https) $secondaryID .= 'https+';
if($pageNum > 1) $secondaryID .= "page{$pageNum}";
$secondaryID = rtrim($secondaryID, '+');
if($this->wire()->languages) {
$language = $this->wire()->user->language;
if($language && $language->id && !$language->isDefault()) $secondaryID .= "_" . $language->id;
}
if($secondaryID) $cacheFile->setSecondaryID($secondaryID);
}
return $cacheFile;
}
/**
* Clear all cached pages
*
* @param Page $page
* @throws WireException
*
*/
public function ___clearCacheFileAll(Page $page) {
if($page->template->cache_time > 0) {
$cacheFile = $this->getCacheFile($page);
$cacheFile->expireAll();
}
if($this->config->debug && $page->template->cache_time != 0) {
$this->message($this->_('Expired page cache for entire site'));
}
}
/**
* Clear cache for multiple pages by ID
*
* @param PageArray $items
* @param Page $page Page that initiated the clear
* @throws WireException
*
*/
public function ___clearCacheFilePages(PageArray $items, Page $page) {
foreach($items as $p) {
if(((int) $p->template->cache_time) < 1) continue;
$cf = $this->getCacheFile($p);
if($cf->exists()) $cf->remove();
}
}
/**
* Hook to clear the cache file after a Pages::save or Pages::delete call
*
* @param HookEvent $event
*
*/
public function clearCacheFile($event) {
/** @var Page $page */
$page = $event->arguments[0];
$template = $page->template;
if(((int) $template->cache_time) == 0) return;
$cacheExpire = $template->cacheExpire;
if($cacheExpire == Template::cacheExpireNone) {
if($event->method == 'delete') {
$cacheExpire = Template::cacheExpirePage;
} else {
return;
}
}
if($cacheExpire == Template::cacheExpireSite) {
// expire entire cache
$this->clearCacheFileAll($page);
} else {
// clear the page that was saved
if($template->cache_time > 0) {
$cacheFile = $this->getCacheFile($page);
if($cacheFile->exists()) {
$cacheFile->remove();
$this->message($this->_('Cleared cache file:') . " $cacheFile", Notice::debug);
}
}
$pageIDs = array();
if($cacheExpire == Template::cacheExpireParents || $cacheExpire == Template::cacheExpireSpecific) {
// expire specific pages or parents
if($cacheExpire == Template::cacheExpireParents) {
foreach($page->parents as $parent) {
$pageIDs[] = $parent->id;
}
} else if(is_array($template->cacheExpirePages) && count($template->cacheExpirePages)) {
$pageIDs = $template->cacheExpirePages;
}
} else if($cacheExpire == Template::cacheExpireSelector && $template->cacheExpireSelector) {
// expire pages matching a selector
/** @var PageFinder $finder */
$finder = $this->wire(new PageFinder());
$selectors = new Selectors();
$this->wire($selectors);
$selectors->init($template->cacheExpireSelector);
$pageIDs = $finder->findIDs($selectors, array(
'getTotal' => false,
'findHidden' => true
));
}
if(count($pageIDs)) {
$items = $this->wire()->pages->getById($pageIDs, array(
'cache' => false,
'getNumChildren' => false,
'autojoin' => false,
'findTemplates' => false,
'joinSortfield' => false
));
if(!$items->has($page)) $items->add($page);
} else {
$items = new PageArray();
$this->wire($items);
$items->add($page);
}
if(count($items)) {
$this->clearCacheFilePages($items, $page);
$this->message(sprintf($this->_('Cleared cache file for %d page(s)'), count($items)), Notice::debug);
}
}
}
/**
* Hook called when cache file is about to be saved for a Page
*
* #pw-hooker
*
* @param Page $page
* @param string $data Data that will be saved to cache file
* @return string Data to save to cache file
* @since 3.0.178
*
*/
public function ___saveCacheFileReady(Page $page, $data) {
return $data;
}
/**
* Hook called before any other hooks to Page::render
*
* We use this to determine if Page::render() should be a render() or a renderField()
*
* @param HookEvent $event
*
*/
public function beforeRenderPage(HookEvent $event) {
$fieldName = $event->arguments(0);
if($fieldName && is_string($fieldName) && $this->wire()->sanitizer->fieldName($fieldName) === $fieldName) {
// render field requested, cancel page render and hooks, and delegate to renderField
$file = $event->arguments(1); // optional basename of file to use for render
if(!is_string($file)) $file = null;
$event->cancelHooks = true;
$event->replace = true;
/** @var Page $page */
$page = $event->object;
$event->return = $page->renderField($fieldName, $file);
}
}
/**
* Return a string with the rendered output of this Page from its template file
*
* This method provides the implementation for `$page->render()` and you sould only call this method as `render()` from Page objects.
* You may optionally specify 1-2 arguments to the method. The first argument may be an array of options OR filename (string) to render.
* If specifying a filename in the first argument, you can optionally specify the $options array as the 2nd argument.
* If using the options argument, you may specify your own variables to pass along to your template file, and those values will be
* available in a variable named `$options` within the scope of the template file (see examples below).
*
* In addition, the following options are present and recognized by the core:
*
* - `forceBuildCache` (bool): If true, the cache will be re-created for this page, regardless of whether its expired or not. (default=false)
* - `allowCache` (bool): Allow cache to be used when template settings ask for it. (default=true)
* - `filename` (string): Filename to render, optionally relative to /site/templates/. Absolute paths must resolve somewhere in PWs install. (default=blank)
* - `prependFile` (string): Filename to prepend to output, must be in /site/templates/.
* - `prependFiles` (array): Array of additional filenames to prepend to output, must be relative to /site/templates/.
* - `appendFile` (string): Filename to append to output, must be in /site/templates/.
* - `appendFiles` (array): Array of additional filenames to append to output, must be relative to /site/templates/.
* - `pageStack` (array): An array of pages, when recursively rendering. Used internally. You can examine it but not change it.
*
* Note that the prepend and append options above have default values that come values configured in `$config` or the Template object.
*
* ~~~~~
* // regular page render call
* $output = $page->render();
*
* // render using given file (in /site/templates/)
* $output = $page->render('basic-page.php');
*
* // render while passing in custom variables
* $output = $page->render([
* 'firstName' => 'Ryan',
* 'lastName' => 'Cramer'
* ]);
*
* // in your template file, you can access the passed-in variables like this:
* echo "<p>Full name: $options[firstName] $options[lastName]</p>";
* ~~~~~
*
* Note: If the pages template has caching enabled, then this method will return a cached page render (when available)
* or save a new cache. Caches are only saved on guest users.
*
*
* @param HookEvent $event
* @throws WirePermissionException|Wire404Exception|WireException
*
*/
public function ___renderPage($event) {
/** @var Page $page */
$page = $event->object;
/** @var Template $template */
$template = $page->template;
$this->wire()->pages->setOutputFormatting(true);
if($page->status >= Page::statusUnpublished && !$page->viewable()) {
throw new WirePermissionException("Page '{$page->url}' is not currently viewable.");
}
$_page = $this->wire()->page; // just in case one page is rendering another, save the previous
$config = $this->wire()->config;
$timerKey = $config->debug ? "page.$page.render" : "";
$compiler = null; /** @var FileCompiler|null $compiler */
$compilerOptions = array();
if($timerKey) Debug::timer($timerKey);
if($config->templateCompile && $template->compile) {
$compilerOptions = array(
'namespace' => strlen(__NAMESPACE__) > 0,
'includes' => $template->compile >= 2 ? true : false,
'modules' => true,
'skipIfNamespace' => $template->compile == 3 ? true : false,
);
$compiler = $this->wire(new FileCompiler($config->paths->templates, $compilerOptions));
}
$this->renderRecursionLevel++;
// set the context of the new page to be system-wide
// only applicable if rendering a page within a page
if(!$_page || $page->id != $_page->id) $this->wire('page', $page);
if($this->renderRecursionLevel > 1) $this->pageStack[] = $_page;
// arguments to $page->render() may be a string with filename to render or array of options
$options = $event->arguments(0);
$options2 = $event->arguments(1);
// normalize options to array
if(is_string($options) && strlen($options)) $options = array('filename' => $options); // arg1 is filename
if(!is_array($options)) $options = array(); // no args specified
if(is_array($options2)) $options = array_merge($options2, $options); // arg2 is $options
$defaultOptions = array(
'filename' => '', // default blank means filename comes from $page
'prependFile' => $template->noPrependTemplateFile ? null : $config->prependTemplateFile,
'prependFiles' => $template->prependFile ? array($template->prependFile) : array(),
'appendFile' => $template->noAppendTemplateFile ? null : $config->appendTemplateFile,
'appendFiles' => $template->appendFile ? array($template->appendFile) : array(),
'allowCache' => true,
'forceBuildCache' => false,
'pageStack' => array(), // set after array_merge
);
$options = array_merge($defaultOptions, $options);
$options['pageStack'] = $this->pageStack;
$cacheAllowed = $options['allowCache'] && $this->isCacheAllowed($page);
$cacheFile = null;
if($cacheAllowed) {
$cacheFile = $this->getCacheFile($page, $options);
if(!$options['forceBuildCache'] && ($data = $cacheFile->get()) !== false) {
$event->return = $data;
if($_page) $this->wire('page', $_page);
if($timerKey) Debug::saveTimer($timerKey);
return;
}
}
$of = $page->of();
if(!$of) $page->of(true);
$data = '';
$output = $page->output(true);
if($output) {
// global prepend/append include files apply only to user-defined templates, not system templates
if(!($template->flags & Template::flagSystem)) {
foreach(array('prependFile' => 'prependFiles', 'appendFile' => 'appendFiles') as $singular => $plural) {
if($options[$singular]) array_unshift($options[$plural], $options[$singular]);
foreach($options[$plural] as $file) {
if(!ctype_alnum(str_replace(array(".", "-", "_", "/"), "", $file))) continue;
if(strpos($file, '..') !== false || strpos($file, '/.') !== false) continue;
$file = $config->paths->templates . trim($file, '/');
if(!is_file($file)) continue;
if($compiler && $compilerOptions['includes']) {
$file = $compiler->compile($file);
}
if($plural == 'prependFiles') {
$output->setPrependFilename($file);
} else {
$output->setAppendFilename($file);
}
}
}
}
// option to change the filename that is used for output rendering
if($options['filename'] && strpos($options['filename'], '..') === false) {
$filename = $config->paths->templates . ltrim($options['filename'], '/');
$setFilename = '';
if(is_file($filename)) {
// path relative from /site/templates/
$setFilename = $filename;
} else {
// absolute path, ensure it is somewhere within web root
$filename = $options['filename'];
if(strpos($filename, $config->paths->root) === 0 && is_file($filename)) $setFilename = $filename;
}
if($setFilename) {
if($compiler) {
$output->setChdir(dirname($setFilename));
$setFilename = $compiler->compile($setFilename);
}
$output->setFilename($setFilename);
$options['filename'] = $setFilename;
} else {
throw new WireException("Invalid output file location or specified file does not exist. $setFilename");
}
} else {
if($compiler) {
$options['filename'] = $compiler->compile($template->filename);
$output->setFilename($options['filename']);
$output->setChdir(dirname($template->filename));
} else {
$options['filename'] = $template->filename;
}
}
// pass along the $options as a local variable to the template so that one can provide their
// own additional variables in it if they want to
$output->set('options', $options);
/** @var WireProfilerInterface $profiler */
$profiler = $this->wire('profiler');
$profilerEvent = $profiler ? $profiler->start($page->path, $this, array('page' => $page)) : null;
$data = $output->render();
if($profilerEvent) $profiler->stop($profilerEvent);
if(!strlen($data) && $page->template->name === 'admin' && !is_readable($options['filename'])) {
throw new WireException('Missing or non-readable template file: ' . basename($options['filename']));
}
}
if($config->useMarkupRegions) {
$contentType = $template->contentType;
if(empty($contentType) || stripos($contentType, 'html') !== false) {
$this->populateMarkupRegions($data);
}
}
if($timerKey) Debug::saveTimer($timerKey);
if($data && $cacheAllowed && $cacheFile) {
$data = $this->saveCacheFileReady($page, $data);
$cacheFile->save($data);
}
$event->return = $data;
if(!$of) $page->of($of);
if($_page && $_page->id != $page->id) {
$this->wire('page', $_page);
}
if(count($this->pageStack)) array_pop($this->pageStack);
$this->renderRecursionLevel--;
}
/**
* Populate markup regions directly to $html
*
* @param $html
*
*/
protected function populateMarkupRegions(&$html) {
$markupRegions = new WireMarkupRegions();
$this->wire($markupRegions);
$pos = stripos($html, '<!DOCTYPE html');
if($pos === false) {
// if no doctype match, attempt an html tag match
$pos = stripos($html, '<html');
}
// if no document start, or document starts at pos==0, then nothing to populate
if(!$pos) {
// there still may be region related stuff that needs to be removed like <region> tags
$markupRegions->removeRegionTags($html);
$html = $markupRegions->stripOptional($html);
return;
}
// split document at doctype/html boundary
$htmlBefore = substr($html, 0, $pos);
$html = substr($html, $pos);
$options = array('useClassActions' => true);
$config = $this->wire()->config;
$version = (int) $config->useMarkupRegions;
if($config->installed >= 1498132609 || $version >= 2) {
// If PW installed after June 21, 2017 do not use legacy class actions
// as they are no longer part of the current WireMarkupRegions spec.
// Can also force this behavior by setting $config->useMarkupRegions = 2;
$options['useClassActions'] = false;
}
$markupRegions->populate($html, $htmlBefore, $options);
}
/**
* Renders a field value
*
* if $fieldName is omitted (blank), a $file and $value must be provided
*
* @param Page $page
* @param string $fieldName
* @param string $file
* @param mixed $value
* @return string|mixed
*
*/
public function renderField(Page $page, $fieldName, $file = '', $value = null) {
$sanitizer = $this->wire()->sanitizer;
$config = $this->wire()->config;
if(strlen($fieldName)) {
$fieldName = $sanitizer->fieldName($fieldName);
}
if(is_null($value) && $fieldName) $value = $page->getFormatted($fieldName);
if(is_null($value)) return '';
if($fieldName) {
$field = $page->getField($fieldName);
if(!$field) $field = $this->wire()->fields->get($fieldName);
$fieldtypeName = $field && $field->type ? $field->type->className() : '';
} else {
$field = null;
$fieldtypeName = '';
}
$path = $config->paths->fieldTemplates;
$files = array();
if($file) {
// a render file or path was specified
if(strpos($file, '\\') !== false) $file = str_replace('\\', '/', $file);
$hasTrailingSlash = substr($file, -1) == '/';
$hasLeadingSlash = strpos($file, '/') === 0;
$file = trim($file, '/');
if(substr($file, -4) == '.php') $file = substr($file, 0, -4);
if($hasLeadingSlash && $file) {
// VERY SPECIFIC
// use only what was specified
$files[] = $file;
} else if(!$hasTrailingSlash && strpos($file, '/') !== false) {
// SPECIFIC RENDER FILE
// file includes a directory off of fields/[dir]
$parts = explode('/', $file);
foreach($parts as $key => $part) {
$parts[$key] = $sanitizer->name($part);
}
$file = implode('/', $parts);
$file = str_replace('..', '', $file);
// i.e. fields/custom_dir/custom_name.php
$files[] = $file;
} else if($hasTrailingSlash && $fieldName) {
// GROUP DIRECTORY
// specifies a group name, referring to a directory, i.e. "group_name/"
// i.e. fields/custom_name/field_name.php
$files[] = "$file/$fieldName";
} else if($fieldName) {
// FIELD DIRECTORY WITH CUSTOM NAMED RENDER FILE
// i.e. fields/field_name/custom_name.php
$files[] = "$fieldName/$file";
// GROUP DIRECTORY WITH FIELD NAMED RENDER FILE
// i.e. fields/field_name/custom_name.php
$files[] = "$file/$fieldName";
// CUSTOM NAMED RENDER FILE ONLY (NO GROUP)
// i.e. fields/custom_name.php
$files[] = $file;
} else {
$files[] = $file;
}
} else if($fieldName) {
// no render file was specified, check for possible template context files
if(strpos($fieldtypeName, 'Repeater') === false) {
// FIELD DIRECTORY WITH TEMPLATE NAME
// i.e. fields/field_name/template_name.php
$files[] = "$fieldName/{$page->template->name}";
}
// TEMPLATE DIRECTORY WITH FIELD NAME
// i.e. fields/template_name/field_name.php
$files[] = "{$page->template->name}/$fieldName";
// FIELD NAME WITH TEMPLATE NAME
// i.e. fields/field_name.template_name.php
$files[] = "$fieldName.{$page->template->name}";
}
// LAST FALLBACK/DEFAULT
// i.e. fields/field_name.php
if($fieldName) $files[] = $fieldName;
$renderFile = '';
foreach($files as $f) {
$file = "$path$f.php";
if(!is_file($file)) continue;
$renderFile = $file;
break;
}
if(!$renderFile) {
if($fieldName) {
return $page->getMarkup($fieldName);
} else {
return '';
}
}
if($config->templateCompile) {
$renderFile = $this->wire()->files->compile($renderFile, array('skipIfNamespace' => true));
}
/** @var TemplateFile $tpl */
$tpl = $this->wire(new TemplateFile($renderFile));
$tpl->set('page', $page);
$tpl->set('value', $value);
$tpl->set('field', $field);
return $tpl->render();
}
/**
* Provide a disk cache clearing capability within the module's configuration screen
*
* @param array $data
* @return InputfieldWrapper
*
*/
public function getModuleConfigInputfields(array $data) {
$config = $this->wire()->config;
$input = $this->wire()->input;
$files = $this->wire()->files;
$modules = $this->wire()->modules;
if($data) {}
$path = $config->paths->cache . self::cacheDirName . '/';
$numPages = 0;
$numFiles = 0;
$inputfields = $this->wire(new InputfieldWrapper());
$dir = null;
$clearNow = $input->post('_clearCache') ? true : false;
try { $dir = new \DirectoryIterator($path); } catch(\Exception $e) { }
if($dir) foreach($dir as $file) {
if(!$file->isDir() || $file->isDot() || !ctype_digit($file->getFilename())) continue;
$numPages++;
if(!$clearNow) continue;
$d = new \DirectoryIterator($file->getPathname());
foreach($d as $f) {
if(!$f->isDir() && preg_match('/\.cache$/D', $f->getFilename())) {
$numFiles++;
$files->unlink($f->getPathname());
}
}
$files->rmdir($file->getPathname());
}
if($clearNow) {
$inputfields->message(sprintf($this->_('Cleared %d cache files for %d pages'), $numFiles, $numPages));
$numPages = 0;
}
/** @var InputfieldCheckbox $f */
$f = $modules->get('InputfieldCheckbox');
$f->attr('name', '_clearCache');
$f->attr('value', 1);
$f->label = $this->_('Clear the Page Render Disk Cache?');
$f->description = sprintf($this->_('There are currently %d pages cached in %s'), $numPages, $path);
$inputfields->append($f);
return $inputfields;
}
}