__('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 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 "

Full name: $options[firstName] $options[lastName]

"; * ~~~~~ * * 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; $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, ' 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; } }