<?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 (other usages available as well)
	 *
	 * 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 any of the following: 
	 * 
	 * 1. An $options array with predefined options or custom variables (see available options further below). 
	 * 2. A filename to use for render (in /site/templates/). When used, you can optionally specify the $options array as the 2nd argument.
	 * 3. A field name to render. An optional 2nd argument can be a filename (in /site/templates/fields/) to use to render the field.
	 *    Please see the `Page::renderField()` method for more details for this usage (you may want to use that method instead). 
	 * 
	 * If using the $options array argument (whether 1st or 2nd argument), you may use it to 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 have defaults 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;
		$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;
	}

}
