diff --git a/wire/core/WireHooks.php b/wire/core/WireHooks.php index 8858a8c4..5e4c77de 100644 --- a/wire/core/WireHooks.php +++ b/wire/core/WireHooks.php @@ -830,11 +830,12 @@ class WireHooks { $filterPath = trim(str_replace(array('-', '_', '.'), '/', $path), '/'); foreach(explode('/', $filterPath) as $filter) { // identify any non-regex portions to use as pre-filters before using regexes + // @todo see if this can be improved further to include slash positions if(ctype_alnum($filter) && strlen($filter) > 1) $filters[] = $filter; } $this->pathHooks[$id] = array( 'match' => $path, - 'filters' => array(), + 'filters' => $filters, ); return $id; } @@ -964,8 +965,9 @@ class WireHooks { } if($this->allowPathHooks && isset($this->pathHooks[$hook['id']])) { - if(!$this->allowRunPathHook($hook, $arguments)) continue; + $allowRunPathHook = $this->allowRunPathHook($hook['id'], $arguments); $this->removeHook($object, $hook['id']); // once only + if(!$allowRunPathHook) continue; $useHookReturnValue = true; } @@ -1063,22 +1065,23 @@ class WireHooks { * regular and regex matches and populating parenthesized portions to arguments * that will appear in the HookEvent. * - * @param array $hook + * @param string $id Hook ID * @param array $arguments * @return bool * @since 3.0.173 * */ - protected function allowRunPathHook(array $hook, array &$arguments) { + protected function allowRunPathHook($id, array &$arguments) { - $id = $hook['id']; $pathHook = $this->pathHooks[$id]; $matchPath = $pathHook['match']; $requestPath = $arguments[0]; $slashed = substr($requestPath, -1) === '/' && strlen($requestPath) > 1; $filterFail = false; $regexDelim = ''; // populated only for user-specified regex - + $pageNum = $this->wire->wire()->input->pageNum(); + $pageNumArgument = 0; + // first pre-filter the requestPath against any words matchPath (filters) foreach($pathHook['filters'] as $filter) { if(strpos($requestPath, $filter) !== false) continue; @@ -1096,6 +1099,17 @@ class WireHooks { if(strpos($matchPath, '/') === 0) $matchPath = "^$matchPath"; $matchPath = "#$matchPath$#"; } + + if(strpos($matchPath, '{pageNum}') !== false) { + // the {pageNum} named argument maps to $input->pageNum. remove the {pageNum} argument + // from the match path since it is handled differently from other named arguments + $matchPath = str_replace(array('/{pageNum}/', '/{pageNum}'), '/', $matchPath); + $pathHook['match'] = str_replace(array('/{pageNum}/', '/{pageNum}'), '/', $pathHook['match']); + $pageNumArgument = $pageNum; + } else if($pageNum > 1) { + // hook does not handle pagination numbers above 1 + return false; + } if(strpos($matchPath, ':') && strpos($matchPath, '(') !== false) { // named arguments in format “(name: value)” converted to named PCRE capture groups @@ -1136,6 +1150,7 @@ class WireHooks { // success: at this point the requestPath has matched $arguments['path'] = $arguments[0]; + if($pageNumArgument) $arguments['pageNum'] = $pageNumArgument; foreach($matches as $key => $value) { // populate requested arguments @@ -1270,14 +1285,42 @@ class WireHooks { /** * Return whether or not any path hooks are pending * - * @return int + * @param string $requestPath Optionally provide request path to determine if any might match (3.0.174+) + * @return bool * @since 3.0.173 * */ - public function hasPathHooks() { + public function hasPathHooks($requestPath = '') { + // first pre-filter the requestPath against any words matchPath (filters) + if(strlen($requestPath)) return $this->filterPathHooks($requestPath, true); return count($this->pathHooks) > 0; } + /** + * Return path hooks that have potential to match given request path + * + * @param string $requestPath + * @param bool $has Specify true to change return value to boolean as to whether any can match (default=false) + * @return array|bool + * @since 3.0.174 + * + */ + public function filterPathHooks($requestPath, $has = false) { + $pathHooks = array(); + foreach($this->pathHooks as $id => $pathHook) { + $fail = false; + foreach($pathHook['filters'] as $filter) { + $fail = strpos($requestPath, $filter) === false; + if($fail) break; + } + if(!$fail) { + $pathHooks[$id] = $pathHook; + if($has) break; + } + } + return $has ? count($pathHooks) > 0 : $pathHooks; + } + /** * Get or set whether path hooks are allowed * diff --git a/wire/modules/Process/ProcessPageView.module b/wire/modules/Process/ProcessPageView.module index 85df8ca4..787849b2 100644 --- a/wire/modules/Process/ProcessPageView.module +++ b/wire/modules/Process/ProcessPageView.module @@ -226,12 +226,19 @@ class ProcessPageView extends Process { } } catch(Wire404Exception $e) { - return $this->pageNotFound($page, $this->requestPath, false, '404 thrown during page render', $e); + // 404 exception + return $this->renderNoPage(array( + 'reason404' => '404 thrown during page render', + 'exception404' => $e, + 'page' => $page, + 'ready' => true, // let it know ready state already executed + )); } catch(\Exception $e) { + // other exception (re-throw non 404 exceptions) $this->responseType = self::responseTypeError; $this->failed($e, "Thrown during page render", $page); - throw $e; // re-throw non-404 Exceptions + throw $e; } return ''; @@ -240,31 +247,71 @@ class ProcessPageView extends Process { /** * Render when no page mapped to request URL * + * @param array $options * @return array|bool|false|string * @throws WireException * @since 3.0.173 * */ - protected function renderNoPage() { - + protected function renderNoPage(array $options = array()) { + + $defaults = array( + 'allow404' => true, // allow 404 to be thrown? + 'reason404' => 'Requested URL did not resolve to a Page', + 'exception404' => null, + 'ready' => false, // are we executing from the API ready state? + 'page' => $this->http404Page(), // potential Page object (default is 404 page) + ); + + $options = count($options) ? array_merge($defaults, $options) : $defaults; $config = $this->wire()->config; $hooks = $this->wire()->hooks; + $input = $this->wire()->input; + $requestPath = $this->requestPath; + $setPageNum = 0; + $pageNumSegment = ''; $page = null; $out = false; - $page404 = $this->http404Page(); + $this->setResponseType(self::responseTypeNoPage); - $this->wire('page', $page404); + + if($this->pageNum > 0 && $this->pageNumPrefix !== null) { + // there is a pagination segment present in the request path + $slash = substr($requestPath, -1) === '/' ? '/' : ''; + $requestPath = rtrim($requestPath, '/'); + $pageNumSegment = $this->pageNumPrefix . $this->pageNum; + if(substr($requestPath, -1 * strlen($pageNumSegment)) === $pageNumSegment) { + // remove pagination segment from request path + $requestPath = substr($requestPath, 0, -1 * strlen($pageNumSegment)); + $setPageNum = $this->pageNum; + // disallow specific "/page1" in URL as it is implied by the lack of pagination segment + if($setPageNum === 1) $this->redirect($config->urls->root . ltrim($requestPath, '/')); + // enforce no trailing slashes for pagination numbers + if($slash) $this->redirect($config->urls->root . ltrim($requestPath, '/') . $pageNumSegment); + $input->setPageNum($this->pageNum); + } else { + // not a pagination segment + // add the slash back to restore requestPath + $requestPath .= $slash; + $pageNumSegment = ''; + } + } + + if(!$options['ready']) $this->wire('page', $options['page']); // run up to 2 times, once before ready state and once after for($n = 1; $n <= 2; $n++) { + + // only run once if already in ready state + if($options['ready']) $n = 2; // call ready() on 2nd iteration only, allows for ready hooks to set $page - if($n === 2) $this->ready(); + if($n === 2 && !$options['ready']) $this->ready(); if(!$hooks->hasPathHooks()) continue; $this->setResponseType(self::responseTypePathHook); - $out = $this->pathHooks($this->requestPath, $out); + $out = $this->pathHooks($requestPath, $out); // allow for pathHooks() $event->return to persist between init and ready states // this makes it possible for ready() call to examine $event->return from init() call @@ -281,7 +328,7 @@ class ProcessPageView extends Process { } // first hook that determines the $page wins - if($page && $page->id && $page->id !== $page404->id) break; + if($page && $page->id && $page->id !== $options['page']->id) break; $this->setResponseType(self::responseTypeNoPage); } @@ -289,13 +336,17 @@ class ProcessPageView extends Process { // did a path hook require a redirect for trailing slash (vs non-trailing slash)? $redirect = $hooks->getPathHookRedirect(); if($redirect) { - $this->redirect($config->urls->root . ltrim($redirect, '/')); + // path hook suggests a redirect for proper URL format + $url = $config->urls->root . ltrim($redirect, '/'); + // if present, add pagination segment back into URL + if($pageNumSegment) $url = rtrim($url, '/') . "/$pageNumSegment"; + $this->redirect($url); } $this->pathHooksReturnValue = false; // no longer applicable once this line reached $hooks->allowPathHooks(false); // no more path hooks allowed - if($page && $page->id && $page->id !== $page404->id && $page instanceof Page) { + if($page && $page->id && $page instanceof Page && $page->id !== $options['page']->id) { // one of the path hooks set the page $this->wire('page', $page); return $this->renderPage($page); @@ -303,7 +354,15 @@ class ProcessPageView extends Process { if($out === false) { // hook failed to handle request - return $this->pageNotFound(new NullPage(), $this->requestPath, false, 'Requested URL did not resolve to a Page'); + if($setPageNum > 1) $input->setPageNum(1); + if($options['allow404']) { + $page = $options['page']; + // hooks to pageNotFound() method may expect NullPage rather than 404 page + if($page->id == $config->http404PageID) $page = $this->wire(new NullPage()); + $out = $this->pageNotFound($page, $this->requestPath, false, $options['reason404'], $options['exception404']); + } else { + $out = false; + } } else if($out === true) { // hook silently handled the request @@ -1350,7 +1409,7 @@ class ProcessPageView extends Process { } } else { $prefix = $config->pageNumUrlPrefix; - if(strlen($prefix)) $retrnValue[$prefix] = $prefix; + if(strlen($prefix)) $returnValue[$prefix] = $prefix; } return $returnValue;