artabro/wire/modules/PageRender.module

840 lines
26 KiB
Text
Raw Normal View History

2024-08-27 11:35:37 +02:00
<?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;
}
}