Blame | Last modification | View Log | Download
<?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 2016 by Ryan Cramer* https://processwire.com** @method renderPage(HookEvent $event)* @method clearCacheFileAll(Page $page)* @method clearCacheFilePages(PageArray $items, Page $page)**/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');// $this->addHook('Page::renderField', $this, 'renderField');$this->wire('pages')->addHookAfter('save', $this, 'clearCacheFile');$this->wire('pages')->addHookAfter('delete', $this, 'clearCacheFile');// $this->addHookAfter('Fieldtype::savePageField', $this, 'savePageField'); // removed, see note in commented function}/*** API ready**/public function ready() {if($this->wire('page')->template != 'admin') {$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) {if(!$page->template || ((int) $page->template->cache_time) < 1) return false;if(!$this->wire('user')->isGuest()) {if(!$page->template->useCacheForUsers) return false;if($page->editable()) return false;}$allowed = true;if(count($_GET) && $page->template->noCacheGetVars) {if(strpos($page->template->noCacheGetVars, '*') !== false) {$allowed = false;} else {$vars = explode(' ', $page->template->noCacheGetVars);foreach($vars as $name) if($name && isset($_GET[$name])) $allowed = false;}}if($allowed && count($_POST) && $page->template->noCachePostVars) {if(strpos($page->template->noCachePostVars, '*') !== false) {$allowed = false;} else {$vars = explode(' ', $page->template->noCachePostVars);foreach($vars as $name) if($name && isset($_POST[$name])) $allowed = false;}}// NOTE: other modules may set a session var of PageRenderNoCachePage containing a page ID to temporarily// remove caching for some page, if necessary.if($this->wire('session')->PageRenderNoCachePage && $this->wire('session')->PageRenderNoCachePage == $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");if($config->chmodDir) chmod($path, octdec($config->chmodDir));}$cacheFile = new CacheFile($path, $id, $cacheTime);if($this->wire('page') === $page) {// this part is skipped if arguments provided an id (int) rather than a Page object$secondaryID = '';$pageNum = $this->wire('input')->pageNum;$urlSegments = $this->wire('input')->urlSegments;if(count($urlSegments)) {foreach($urlSegments as $urlSegment) {$secondaryID .= $this->wire('sanitizer')->pageName($urlSegment) . '+';}}if($options['prependFile'] || $options['appendFile'] || $options['filename']) {$secondaryID .= md5($options['prependFile'] . '+' . $options['appendFile'] . '+' . $options['filename']) . '+';}if($config->ajax) $secondaryID .= 'ajax+'; // #1262if($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) {if($page) {}foreach($items as $p) {if(((int) $p->template->cache_time) < 1) continue;$cf = $this->getCacheFile($p);if($cf->exists()) $cf->remove();// if($this->config->debug) $this->message(sprintf($this->_('Cleared cache file: %s'), $cf));}}/*** Hook to clear the cache file after a Pages::save or Pages::delete call** @param HookEvent $event**/public function clearCacheFile($event) {$page = $event->arguments[0];if(((int) $page->template->cache_time) == 0) return;$cacheExpire = $page->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 savedif($page->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 parentsif($cacheExpire == Template::cacheExpireParents) {foreach($page->parents as $parent) $pageIDs[] = $parent->id;} else if(is_array($page->template->cacheExpirePages) && count($page->template->cacheExpirePages)) {$pageIDs = $page->template->cacheExpirePages;}} else if($cacheExpire == Template::cacheExpireSelector && $page->template->cacheExpireSelector) {// expire pages matching a selector$finder = $this->wire(new PageFinder());$selectors = new Selectors();$selectors->init($page->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();$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 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 renderif(!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 it’s 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 PW’s 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 page’s 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');$compiler = null;$compilerOptions = array();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 pageif(!$_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 arrayif(is_string($options) && strlen($options)) $options = array('filename' => $options); // arg1 is filenameif(!is_array($options)) $options = array(); // no args specifiedif(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);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 templatesif(!($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 renderingif($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);$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($this->wire('config')->useMarkupRegions) {$contentType = $template->contentType;if(empty($contentType) || stripos($contentType, 'html') !== false) {$this->populateMarkupRegions($data);}}if($data && $cacheAllowed && $cacheFile) $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 populateif(!$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) {/*if(strpos($fieldName, '/') && empty($file)) {$file = $fieldName;$fieldName = '';}*/if(strlen($fieldName)) {$fieldName = $this->wire('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 = $this->wire('config')->paths->fieldTemplates;$files = array();if($file) {// a render file or path was specifiedif(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] = $this->wire('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 filesif(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.phpif($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($this->wire('config')->templateCompile) {$renderFile = $this->wire('files')->compile($renderFile, array('skipIfNamespace' => true));}$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) {if($data) {}$path = $this->wire('config')->paths->cache . self::cacheDirName . '/';$numPages = 0;$numFiles = 0;$inputfields = $this->wire(new InputfieldWrapper());$dir = null;$clearNow = $this->wire('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++;$this->wire('files')->unlink($f->getPathname());}}$this->wire('files')->rmdir($file->getPathname());}if($clearNow) {$inputfields->message(sprintf($this->_('Cleared %d cache files for %d pages'), $numFiles, $numPages));$numPages = 0;}$name = "clearCache";$f = $this->wire('modules')->get('InputfieldCheckbox');$f->attr('name', $name);$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;}}