pathFinder()`, i.e. * ~~~~~ * $result = $pages->pathFinder()->get('/en/foo/bar/page3'); * ~~~~~ * Note that PagesPathFinder does not perform any access control checks, so if using this class then validate access * afterwards when appropriate. * #pw-body * * ProcessWire 3.x, Copyright 2022 by Ryan Cramer * https://processwire.com * * @todo: * Determine how to handle case where this class is called before * LanguageSupport module has been loaded (for multi-language page names) * */ class PagesPathFinder extends Wire { /** * @var Pages * */ protected $pages; /** * Default options for the get() method * * @var array * * - useLanguages: Allows for selection of multi-language path paths (requires LanguageSupportPageNames module) * - usePathPaths: Allow use of PagePaths module to attempt to find a shortcut? * - useGlobalUnique: Allow support of shortcut for globally unique page names? * - useExcludeRoots: Exclude paths that do not have a known root segment, improves performance when lots of 404s. * - useHistory: Allow detecting of previous page paths via the PagePathHistory module? * - verbose: Return more verbose array that includes details on each path segment? * */ protected $defaults = array( 'useLanguages' => true, 'usePagePaths' => true, 'useGlobalUnique' => true, 'useExcludeRoot' => false, 'useHistory' => true, 'verbose' => true, 'test' => false, ); /** * @var array * */ protected $options = array(); /** * @var bool * */ protected $verbose = true; /** * @var array * */ protected $methods = array(); /** * @var array * */ protected $result = array(); /** * @var Template|null * */ protected $template = null; /** * @var bool|null * */ protected $admin = null; /** * @var array|null * */ protected $useLanguages = null; /** * URL part types (for reference) * * @var array * */ protected $partTypes = array( 'pageName', // references a page name directly 'urlSegment', // part of urlSegmentStr after page path 'pageNum', // pagination number segment 'language', // language segment prefix ); /** * Construct * * @param Pages $pages * */ public function __construct(Pages $pages) { $this->pages = $pages; parent::__construct(); } /** * Init for new get() * * @param string $path * @param array $options * */ protected function init($path, array $options) { $this->options = array_merge($this->defaults, $options); $this->verbose = $this->options['verbose']; $this->methods = array(); $this->useLanguages = $this->options['useLanguages'] ? $this->languages(true) : array(); $this->result = $this->getBlankResult(array('request' => $path)); $this->template = null; $this->admin = null; if(empty($this->pageNameCharset)) { $this->pageNameCharset = $this->wire()->config->pageNameCharset; } } /** * Get verbose array of info about a given page path * * This method accepts a page path (not URL) with optional language segment * prefix, additional URL segments and/or pagination number. It translates * the given path to information about what page it matches, what type of * request it would result in. * * If the `response` property in the return value is 301 or 302, then the * `redirect` property will be populated with a recommend redirect path. * * Please access this method from `$pages->pathFinder()->get('…');` * * Below is an example when given a `$path` argument of `/en/foo/bar/page3` * on a site that has default language homepage segment of `en`, a page living * at `/foo/` that accepts URL segment `bar` and has pagination enabled; * ~~~~~ * $array = $pages->pathFinder()->get('/en/foo/bar/page3'); * ~~~~~ * ~~~~~ * [ * 'request' => '/en/foo/bar/page3', * 'response' => 200, // one of 200, 301, 302, 404, 414 * 'type' => 'ok', // response type name * 'errors' => [], // array of error messages indexed by error name * 'redirect` => '/redirect/path/', // suggested path to redirect to or blank * 'page' => [ * 'id' => 123, // ID of the page that was found * 'parent_id' => 456, * 'templates_id' => 12, * 'status' => 1, * 'name' => 'foo', * ], * 'language' => [ * 'name' => 'default', // name of language detected * 'segment' => 'en', // segment prefix in path (if any) * 'status' => 1, // language status where 1 is on, 0 is off * ], * 'parts' => [ // all the parts of the path identified * [ * 'type' => 'language', * 'value' => 'en', * 'language' => 'default' * ], * [ * 'type' => 'pageName', * 'value' => 'foo', * 'language' => 'default' * ], * [ * 'type' => 'urlSegment', * 'value' => 'bar', * 'language' => '' * ], * [ * 'type' => 'pageNum', * 'value' => 'page3', * 'language' => 'default' * ], * ], * 'urlSegments' => [ // URL segments identified in order * 'bar', * ], * 'urlSegmentStr' => 'bar', // URL segment string * 'pageNum' => 3, // requested pagination number * 'pageNumPrefix' => 'page', // prefix used in pagination number * 'scheme' => 'https', // blank if no scheme required, 'https' or 'http' if one of those is required * 'method' => 'pagesRow', // method(s) that were used to find the page * ] * ~~~~~ * * @param string $path Page path optionally including URL segments, language prefix, pagination number * @param array $options * - `useLanguages` (bool): Allow use of multi-language page names? (default=true) * Requires LanguageSupportPageNames module installed. * - `useHistory` (bool): Allow use historical path names? (default=true) * Requires PagePathHistory module installed. * - `verbose` (bool): Return verbose array of information? (default=true) * If false, some optional information will be omitted in return value. * @return array * @see PagesPathFinder::getPage() * */ public function get($path, array $options = array()) { $this->init($path, $options); // see if we can take a shortcut if($this->getShortcut($path)) return $this->result; // convert path to array of parts (page names) $parts = $this->getPathParts($path); // find a row matching the path/parts in the pages table $row = $this->getPagesRow($parts); $path = $this->applyPagesRow($parts, $row); // if we didn’t match row or matched with URL segments, see if history match available if($this->options['useHistory']) { if(!$row || ($this->result['page']['id'] === 1 && count($this->result['urlSegments']))) { if($this->getPathHistory($this->result['request'])) return $this->result; } } return $this->finishResult($path); } /** * Given a path, get a Page object or NullPage if not found * * This method is like the `get()` method except that it returns a `Page` * object rather than an array. It sets a `_pagePathFinder` property to the * returned Page, which is an associative array containing the same result * array returned by the `get()` method. * * Please access this method from `$pages->pathFinder()->getPage('…');` * * @param string $path * @param array $options * @return NullPage|Page * @see PagesPathFinder::get() * */ public function getPage($path, array $options = array()) { if(!isset($options['verbose'])) $options['verbose'] = false; $result = $this->get($path, $options); if($result['response'] >= 400) { $page = $this->pages->newNullPage(); } else { $page = $this->pages->getOneById($result['page']['id'], array( 'template' => $this->getResultTemplate(), 'parent_id' => $result['page']['parent_id'], )); } $page->setQuietly('_pagePathFinder', $result); return $page; } /** * Find a row for given $parts in pages table * * @param array $parts * @return array|null * */ protected function getPagesRow(array $parts) { $database = $this->wire()->database; $selects = array(); $binds = array(); $joins = array(); $wheres = array(); $lastTable = 'pages'; // build the query to match each part foreach($parts as $n => $name) { $name = $this->pageNameToAscii($name); $key = "p$n"; $table = $n ? $key : 'pages'; $selects[] = "$table.id AS {$key}_id"; $selects[] = "$table.parent_id AS {$key}_parent_id"; $selects[] = "$table.templates_id AS {$key}_templates_id"; $selects[] = "$table.status AS {$key}_status"; $selects[] = "$table.name AS {$key}_name"; $bindKey = "{$key}_name"; $binds[$bindKey] = $name; $whereNames = array("$table.name=:$bindKey"); foreach($this->useLanguages as $lang) { /** @var Language $lang */ if($lang->isDefault()) continue; $whereNames[] = "$table.name$lang->id=:$bindKey"; $selects[] = "$table.name$lang->id AS {$key}_name$lang->id"; $selects[] = "$table.status$lang->id AS {$key}_status$lang->id"; } $whereNames = implode(' OR ', $whereNames); if($n) { $joins[] = "LEFT JOIN pages AS $table ON $table.parent_id=$lastTable.id AND ($whereNames)"; } else { $where = "($table.parent_id=1 AND ($whereNames))"; $wheres[] = $where; } $lastTable = $table; } if(!count($selects)) { $this->addResultNote('No selects from pagesRow'); return null; } $selects = implode(', ', $selects); $joins = implode(" \n", $joins); $wheres = implode(" AND ", $wheres); $sql = "SELECT $selects \nFROM pages \n$joins \nWHERE $wheres"; $query = $database->prepare($sql); foreach($binds as $bindKey => $bindValue) { $query->bindValue(":$bindKey", $bindValue); } $query->execute(); $rowCount = (int) $query->rowCount(); $row = $query->fetch(\PDO::FETCH_ASSOC); $query->closeCursor(); // multiple matches error (not likely) if($rowCount > 1) { $row = null; $this->addMethod('pagesRow', false, 'Multiple matches'); } else if($rowCount) { $this->addMethod('pagesRow', true, "OK [id:$row[p0_id]]"); } else { $this->addMethod('pagesRow', false, "No rows"); } return $row; } /** * Apply a found pages table row to the $result and return corresponding path * * @param array $parts * @param array|null $row * @return string Path string * */ protected function applyPagesRow(array $parts, $row) { $maxUrlSegmentLength = $this->wire()->config->maxUrlSegmentLength; $result = &$this->result; // array of [language name] => [ 'a', 'b', 'c' ] (from /a/b/c/) $namesByLanguage = array('default' => array()); $statusByLanguage = array(); // determine which parts matched and which didn’t foreach($parts as $n => $name) { $key = "p$n"; $id = $row ? (int) $row["{$key}_id"] : 0; if(!$id) { // if it didn’t resolve to DB page name then it is a URL segment if(strlen($name) > $maxUrlSegmentLength) $name = substr($name, 0, $maxUrlSegmentLength); $result['urlSegments'][] = $name; if($this->verbose) { $result['parts'][] = array( 'type' => 'urlSegment', 'value' => $name, 'language' => '' ); } continue; } $names[] = $name; $nameDefault = $this->pageNameToUTF8($row["{$key}_name"]); $namesByLanguage['default'][] = $nameDefault; // this is intentionally re-populated on each iteration $result['page'] = array( 'id' => $id, 'parent_id' => (int) $row["{$key}_parent_id"], 'templates_id' => (int) $row["{$key}_templates_id"], 'status' => (int) $row["{$key}_status"], 'name' => $nameDefault, ); if($this->verbose && $nameDefault === $name) { $result['parts'][] = array( 'type' => 'pageName', 'value' => $name, 'language' => 'default' ); } foreach($this->useLanguages as $language) { /** @var Language $language */ if($language->isDefault()) continue; $nameLanguage = $this->pageNameToUTF8($row["{$key}_name$language->id"]); $statusLanguage = (int) $row["{$key}_status$language->id"]; $statusByLanguage[$language->name] = $statusLanguage; if($nameLanguage != $nameDefault && $nameLanguage === $name) { if($this->verbose) { $result['parts'][] = array( 'type' => 'pageName', 'value' => $nameLanguage, 'language' => $language->name ); } // if there is a part in a non-default language, identify that as the // intended language for the entire path if(empty($result['language']['name'])) $this->setResultLanguage($language); } if(!isset($namesByLanguage[$language->name])) $namesByLanguage[$language->name] = array(); $namesByLanguage[$language->name][] = strlen("$nameLanguage") ? $nameLanguage : $nameDefault; } } // identify if a pageNum is present, must be applied before creation of urlSegmentStr and path $this->applyResultPageNum($parts); $langName = empty($result['language']['name']) ? 'default' : $result['language']['name']; if(!isset($namesByLanguage[$langName])) $langName = 'default'; $path = '/' . implode('/', $namesByLanguage[$langName]); if($langName === 'default') { $result['page']['path'] = $path; } else { $result['page']['path'] = '/' . implode('/', $namesByLanguage['default']); } if(count($this->useLanguages)) { $langStatus = ($langName === 'default' ? 1 : $statusByLanguage[$langName]); $this->setResultLanguageStatus($langStatus); } return $path; } /** * Prepare $path and convert to array of $parts * * If language segment detected then remove it and populate language to result * * @param string $path * @return array|bool * */ protected function getPathParts($path) { $result = &$this->result; $config = $this->wire()->config; $sanitizer = $this->wire()->sanitizer; $maxDepth = $config->maxUrlDepth; $maxUrlSegmentLength = $config->maxUrlSegmentLength; $maxPartLength = $maxUrlSegmentLength > 128 ? $maxUrlSegmentLength : 128; $maxPathLength = ($maxPartLength * $maxUrlSegmentLength) + $maxDepth; $badNames = array(); $path = trim($path, '/'); $lastPart = ''; if($this->strlen($path) > $maxPathLength) { $result['response'] = 414; // 414=URI too long $this->addResultError('pathLengthMAX', "Path length exceeds max allowed $maxPathLength"); $path = substr($path, 0, $maxPathLength); } $parts = explode('/', trim($path, '/')); if(count($parts) > $maxDepth) { $parts = array_slice($parts, 0, $maxDepth); $result['response'] = 414; $this->addResultError('pathDepthMAX', 'Path depth exceeds config.maxUrlDepth'); } else if($path === '/' || $path === '' || !count($parts)) { return array(); } foreach($parts as $n => $name) { $lastPart = $name; if(ctype_alnum($name)) continue; $namePrev = $name; $name = $sanitizer->pageNameUTF8($name); $parts[$n] = $name; if($namePrev !== $name) $badNames[$n] = $namePrev; } if(stripos($lastPart, 'index.') === 0 && preg_match('/^index\.(php\d?|s?html?)$/i', $lastPart)) { array_pop($parts); // removing last part will force a 301 $this->addResultError('indexFile', 'Path had index file'); } if($result['response'] < 400 && count($badNames)) { $result['response'] = 400; // 400=Bad request $this->addResultError('pathBAD', 'Path contains invalid character(s)'); } // identify language from parts and populate to result $this->getPathPartsLanguage($parts); return $parts; } /** * Get language that path is in and apply it to result * * @param array $parts * @return Language|null * */ protected function getPathPartsLanguage(array &$parts) { if(!count($this->useLanguages) || !count($parts)) return null; $languages = $this->languages(); /** @var Languages|array $languages */ if(!count($languages)) return null; $firstPart = reset($parts); $languageKey = array_search($firstPart, $this->languageSegments()); if($languageKey === false) { // if no language segment present then do not identify language yet // allows for page /es/bici/ to still match /bici/ and redirect to /es/bici/ return null; // Comment above and uncomment below to disable language detection without language prefix // $language = $languages->getDefault(); // $segment = $this->languageSegment('default'); } else { $segment = array_shift($parts); $language = $languages->get($languageKey); } if(!$language || !$language->id) return null; $this->addResultNote("Detected language '$language->name' from first segment '$segment'"); $this->setResultLanguage($language, $segment); if($this->verbose && $languageKey !== false) { $this->result['parts'][] = array( 'type' => 'language', 'value' => $segment, 'language' => $language->name ); } // reduce to just applicable language to limit name columns // searched for by getPagesRow() method if($language) $this->useLanguages = array($language); return $language; } /*** RESULT *********************************************************************************/ /** * Build blank result/return value array * * @param array $result Optionally return blank result merged with this one * @return array * */ protected function getBlankResult(array $result = array()) { $blankResult = array( 'request' => '', 'response' => 0, 'type' => '', 'redirect' => '', 'errors' => array(), 'page' => array( 'id' => 0, 'parent_id' => 0, 'templates_id' => 0, 'status' => 0, 'path' => '', ), 'language' => array( 'name' => '', // intentionally blank 'segment' => '', 'status' => -1, // -1=not yet set ), 'parts' => array(), 'urlSegments' => array(), 'urlSegmentStr' => '', 'pageNum' => 1, 'pageNumPrefix' => '', 'pathAdd' => '', // valid URL segments, page numbers, trailing slash, etc. 'scheme' => '', 'methods' => array(), 'notes' => array(), ); if(empty($result)) return $blankResult; $result = array_merge($blankResult, $result); return $result; } /** * Update paths for template info like urlSegments and pageNum and populate urls property * * @param string $path * @return bool|string * */ protected function applyResultTemplate($path) { $config = $this->wire()->config; $fail = false; $result = &$this->result; if(empty($result['page']['templates_id']) && $this->isHomePath($path)) { $this->applyResultHome(); } $template = $this->getResultTemplate(); $slashUrls = $template ? (int) $template->slashUrls : 0; $useTrailingSlash = $slashUrls ? 1 : -1; // 1=yes, 0=either, -1=no $hadTrailingSlash = substr($result['request'], -1) === '/'; $https = $template ? (int) $template->https : 0; $appendPath = ''; // populate urlSegmentStr property if applicable if(empty($result['urlSegmentStr']) && !empty($result['urlSegments'])) { $urlSegments = $result['urlSegments']; $result['urlSegmentStr'] = count($urlSegments) ? implode('/', $urlSegments) : ''; } // if URL segments are present validate them if(strlen($result['urlSegmentStr'])) { if($template && ($template->urlSegments || $template->name === 'admin')) { if($template->isValidUrlSegmentStr($result['urlSegmentStr'])) { $appendPath .= "/$result[urlSegmentStr]"; if($result['pageNum'] < 2) $useTrailingSlash = (int) $template->slashUrlSegments; } else { // ERROR: URL segments did not validate $this->addResultError('urlSegmentsBAD', "Invalid urlSegments for template $template"); $fail = true; } } else { // template does not allow URL segments if($template) { $this->addResultError('urlSegmentsOFF', "urlSegments disabled for template $template"); } $fail = true; } } // if a pageNum is present validate it if($result['pageNum'] > 1) { if($template && $template->allowPageNum) { $maxPageNum = $this->wire()->config->maxPageNum; if($maxPageNum && $result['pageNum'] > $maxPageNum && $template->name != 'admin') { $this->addResultError('pageNumBAD', "pageNum exceeds config.maxPageNum $maxPageNum"); $fail = true; } $segment = $this->pageNumUrlSegment($result['pageNum'], $result['language']['name']); if(strlen($segment)) { $appendPath .= "/$segment"; } $useTrailingSlash = (int) $template->slashPageNum; } else { // template does not allow page numbers $this->addResultError('pageNumOFF', "pageNum disabled for template $template"); $fail = true; } } // determine whether path should end with a trailing slash or not if($useTrailingSlash > 0) { // trailing slash required $appendPath .= '/'; if(!$hadTrailingSlash) $this->addResultNote('Enforced trailing slash'); } else if($useTrailingSlash < 0) { // trailing slash disallowed if($hadTrailingSlash) $this->addResultNote('Enforced NO trailing slash'); } else if($hadTrailingSlash) { // either acceptable, add slash if request had it $appendPath .= '/'; } $_path = $path; if(strlen($appendPath)) $path = rtrim($path, '/') . $appendPath; if($fail || $_path !== $path) $result['redirect'] = '/' . ltrim($path, '/'); $result['pathAdd'] = $appendPath; // determine if page requires specific https vs. http scheme if($https > 0 && !$config->noHTTPS) { // https required $result['scheme'] = 'https'; } else if($https < 0) { // http required (https disallowed) $result['scheme'] = 'http'; } return !$fail; } /** * Apply result for homepage match * */ protected function applyResultHome() { $config = $this->wire()->config; $home = $this->getHomepage(); $this->template = $home->template; $this->result['page'] = array_merge($this->result['page'], array( 'id' => $config->rootPageID, 'templates_id' => $this->template->id, 'parent_id' => 0, 'status' => $home->status )); $this->addMethod('resultHome', true); } /** * Identify and populate language information in result * * @param string $path * @return string $path Path is updated as needed * */ protected function applyResultLanguage($path) { if(!count($this->useLanguages)) return $path; $result = &$this->result; /* if(empty($result['language']['name']) && $path != '/') { // @todo why? return $path; } */ // admin does not use LanguageSupportPageNames if($this->isResultInAdmin()) { return $this->updatePathForLanguage($path, 'default'); } // if(empty($result['language']['name'])) $result['language']['name'] = 'default'; // if there were any non-default language segments, let that dictate the language if(empty($result['language']['segment'])) { $useLangName = 'default'; foreach($result['parts'] as $key => $part) { $langName = $part['language']; if(empty($langName) || $langName === 'default') continue; $useLangName = $langName; break; } $segment = $this->languageSegment($useLangName); if($segment) $result['language']['segment'] = $segment; $result['language']['name'] = $useLangName; } $langName = $result['language']['name']; // prepend the redirect path with the language segment if(!empty($langName)) { $updatePath = $this->updatePathForLanguage($path); $redirect = &$result['redirect']; if(empty($redirect) && $path != $updatePath) $redirect = $updatePath; if(!empty($redirect)) $redirect = $this->updatePathForLanguage($redirect); $path = $updatePath; } // determine language status if not yet known (likely only needed during shortcuts) if($result['language']['status'] < 0 && !empty($langName)) { if($langName === 'default') { $status = 1; } else if($result['page']['id']) { $status = $this->getPageLanguageStatus($result['page']['id'], $this->languageId($langName)); } else { $status = -1; } $this->setResultLanguageStatus($status); } return $path; } /** * Identify and populate pagination number from $result['urlSegments'] * * @param array $parts * */ protected function applyResultPageNum(array &$parts) { $result = &$this->result; $urlSegments = $result['urlSegments']; if(!count($urlSegments)) return; $lastSegment = end($urlSegments); if(!ctype_digit(substr($lastSegment, -1))) return; foreach($this->pageNumUrlPrefixes() as $languageName => $pageNumUrlPrefix) { if(strpos($lastSegment, $pageNumUrlPrefix) !== 0) continue; if(!preg_match('!^' . $pageNumUrlPrefix . '(\d+)$!i', $lastSegment, $matches)) continue; $segment = $matches[0]; $pageNum = (int) $matches[1]; $result['pageNum'] = $pageNum; $result['pageNumPrefix'] = $pageNumUrlPrefix; array_pop($urlSegments); array_pop($parts); if($this->verbose) { array_pop($result['parts']); $result['parts'][] = array( 'type' => 'pageNum', 'value' => $segment, 'language' => (is_string($languageName) ? $languageName : 'default'), ); } break; } $result['urlSegments'] = $urlSegments; } /** * Finish result/return value * * @param string|bool $path Path string or boolean false when 404 * @return array * */ protected function finishResult($path) { $result = &$this->result; $types = $this->pages->request()->getResponseCodeNames(); if($path !== false) $path = $this->applyResultLanguage($path); if($path !== false) $path = $this->applyResultTemplate($path); if($path === false) $result['response'] = 404; $response = &$result['response']; $language = &$result['language']; $errors = &$result['errors']; if($response === 404) { // page not found if(empty($result['errors'])) $result['errors']['pageNotFound'] = "Page not found"; } else if($response == 414) { // path too long } else if($result['request'] === $result['redirect'] || empty($result['redirect'])) { // blank redirect values mean no redirect is suggested $response = 200; $result['redirect'] = ''; } else if($response === 301 || $response === 302) { // redirect identified } else { // redirect suggested $response = 301; } if($result['pageNum'] > 1 && ($response === 301 || $response === 302)) { // redirect where pageNum is greater than 1 $response = $this->finishResultRedirectPageNum($response, $result); } if(empty($language['name'])) { // set language property (likely for non-multi-language install) $language['name'] = 'default'; $language['status'] = 1; } else if($language['name'] != 'default' && !$language['status'] && $result['page']['id']) { // page found but not published in language (needs later decision) $response = 300; // 300 Multiple Choice $errors['languageOFF'] = "Page not active in request language ($language[name])"; $result['redirect'] = $this->pages->getPath($result['page']['id']); if($result['pathAdd']) $result['redirect'] = rtrim($result['redirect'], '/') . $result['pathAdd']; } if(empty($result['type']) && isset($types[$response])) { if($result['response'] === 404 && !empty($result['redirect'])) { // when page found but path not use the 400 response type name w/404 $result['type'] = $types[400]; } else { $result['type'] = $types[$response]; } } $result['methods'] = $this->methods; if(!$this->verbose) unset($result['parts'], $result['methods']); if(empty($errors)) { // force errors placeholder to end if there aren’t any unset($result['errors']); $result['errors'] = array(); } if($this->options['test']) { // add 'test' to the result that should match regardless of options (except useLanguages option) $redirect = ($result['response'] >= 300 && $result['response'] < 400 ? $result['redirect'] : ''); $pageNumStr = ($result['pageNum'] > 1 ? "/$result[pageNumPrefix]$result[pageNum]" : ''); $result['test'] = array( 'response=' . $result['response'], 'page=' . $result['page']['id'], 'redirect=' . $redirect, 'language=' . $result['language']['name'] . '[' . $result['language']['status'] . ']', 'uss=' . $result['urlSegmentStr'] . $pageNumStr, ); $result['test'] = implode(', ', $result['test']); } return $result; } /*** SHORTCUTS ******************************************************************************/ /** * Attempt to match path to page using shortcuts and return true if shortcut found * * @param string $path * @return bool Return true if shortcut found and result ready, false if not * */ protected function getShortcut($path) { $found = false; // $slash = substr($path, -1) === '/' ? '/' : ''; $path = trim($path, '/'); // check for pagination segment, which we don’t want in our path here list($pageNum, $pageNumPrefix) = $this->getShortcutPageNum($path); if(strpos($path, '/') === false) { // single directory off root $found = $this->getShortcutRoot($path); } if(!$found) { if($this->getShortcutExcludeRoot($path)) { $found = true; } else if($this->getShortcutPagePaths($path)) { $found = true; } } if(!$found) return false; $this->result['pageNum'] = $pageNum; $this->result['pageNumPrefix'] = $pageNumPrefix; $this->result = $this->finishResult($path); return true; } /** * @param string $path * @return bool * */ protected function getShortcutRoot($path) { if($path === '') { $this->setResultLanguage('default'); $this->setResultLanguageStatus(1); $this->applyResultHome(); return true; } if(count($this->useLanguages)) { $languageId = $this->isLanguageSegment($path); if($languageId !== false) { $langName = $this->languageName($languageId); $langStatus = $langName === 'default' ? 1 : $this->getHomepage()->get("status$languageId"); $this->setResultLanguage($langName, $path); $this->setResultLanguageStatus($langStatus); $this->applyResultHome(); return true; } } if($this->getShortcutGlobalUnique($path)) { return true; } return false; } /** * Find out if we can early exit 404 based on the root segment * * Unlike other shortcuts, this one is an exclusion shortcut: * Returns false if the root segment matched and further analysis should take place. * Returns true if root segment is not in this site and 404 should be the result. * * @param string $path * @return bool * */ protected function getShortcutExcludeRoot($path) { if(!$this->options['useExcludeRoot']) return false; if(!$this->options['usePagePaths']) return false; $module = $this->pagePathsModule(); if(!$module) return false; $homepage = $this->pages->get((int) $this->wire()->config->rootPageID); // if root/home template allows URL segments then potentially anything // can match the root segment, so this shortcut is not worthwhile if($homepage->template->urlSegments) return false; $config = $this->wire()->config; $path = trim($path, '/'); if(strpos($path, '/')) { list($segment,) = explode('/', $path, 2); } else { $segment = $path; } if(strlen($segment) <= $config->maxUrlSegmentLength) { if($module->isRootSegment($segment)) { $this->addMethod('excludeRoot.paths', true); return false; // root segment found } else { $this->addMethod('excludeRoot.paths', false); } if($this->options['useHistory']) { $module = $this->pagePathHistoryModule(); if($module && $this->options['useHistory']) { if($module->isRootSegment($segment)) { $this->addMethod('excludeRoot.history', true); return false; // root segment found } $this->addMethod('excludeRoot.history', false); } } $response = 404; } else { $response = 414; } // at this point we know given path does not have a valid root segment // and cannot possibly match any page so we can safely stop further // processing and return a 404 not found result $this->result['response'] = $response; $this->addMethod('excludeRoot', $response, 'Early exit for root segment 404'); return true; } /** * Find a shortcut using the PagePaths module * * @param string $path * @return bool Returns true if found, false if not installed or not found * */ protected function getShortcutPagePaths(&$path) { if(!$this->options['usePagePaths']) return false; $module = $this->pagePathsModule(); if(!$module) return false; $result = &$this->result; $info = $module->getPageInfo($path); if(!$info) { $this->addMethod('pagePaths', false); return false; } $language = $this->language((int) $info['language_id']); $path = "/$info[path]"; unset($info['language_id'], $info['path']); $result['page']['id'] = $info['id']; $result['page']['status'] = $info['status']; $result['page']['templates_id'] = $info['templates_id']; $result['page']['parent_id'] = $info['parent_id']; $result['response'] = 200; if($language && $language->id) { $status = $language->isDefault() ? $info['status'] : $info["status$language->id"]; $result['language'] = array_merge($result['language'], array( 'name' => $language->name, 'status' => ($language->status < Page::statusUnpublished ? $status : 0), 'segment' => $this->languageSegment($language) )); } $this->addMethod('pagePaths', true); return true; } /** * Attempt to match a page with status 'unique' or having parent_id=1 * * This method only proceeds if the path contains no slashes, meaning it is 1-level from root. * * @param string $path * @return bool * */ protected function getShortcutGlobalUnique(&$path) { if(!$this->options['useGlobalUnique']) return false; $database = $this->wire()->database; $unique = Page::statusUnique; if(strpos($path, '/') !== false) return false; $name = $this->pageNameToAscii($path); $columns = array('id', 'parent_id', 'templates_id', 'status'); $cols = implode(',', $columns); $sql = "SELECT $cols FROM pages WHERE name=:name AND (parent_id=1 OR (status & $unique))"; $query = $database->prepare($sql); $query->bindValue(':name', $name); $query->execute(); $row = $query->fetch(\PDO::FETCH_ASSOC); $query->closeCursor(); if(!$row) { $this->addMethod('globalUnique', false); return false; } foreach($row as $k => $v) $row[$k] = (int) $v; $result = &$this->result; $result['page'] = array_merge($result['page'], $row); $result['response'] = 200; $result['language']['name'] = 'default'; if($row['parent_id'] === 1) { $template = $this->wire()->templates->get($row['templates_id']); $slashUrls = $template ? (int) $template->slashUrls : 0; $slash = ($slashUrls ? $slashUrls > 0 : substr($path, -1) === '/'); $path = "/$path" . ($slash ? '/' : ''); } else { // global unique, must redirect to its actual path $page = $this->pages->getOneById((int) $row['id'], array( 'template' => (int) $row['templates_id'], 'autojoin' => false, )); if(!$page->id) return false; // not likely $path = $page->path(); } $result['redirect'] = $path; $this->addMethod('globalUnique', true); return true; } /** * Identify shortcut pagination info * * Returns found pagination number, or 1 if first pagination. * Extracts the pagination segment from the path. * * @param string $path * @return array of [ pageNum, pageNumPrefix ] * */ protected function getShortcutPageNum(&$path) { if(!ctype_digit(substr($path, -1))) return array(1, ''); $pos = strrpos($path, '/'); $lastPart = $pos ? substr($path, $pos+1) : $path; $pageNumPrefix = ''; $pageNum = 1; foreach($this->pageNumUrlPrefixes() as $prefix) { if(strpos($lastPart, $prefix) !== 0) continue; if(!preg_match('/^' . $prefix . '(\d+)$/', $lastPart, $matches)) continue; $pageNumPrefix = $prefix; $pageNum = (int) $matches[1]; break; } if($pageNumPrefix) { $path = $pos ? substr($path, 0, $pos) : ''; if($pageNum < 2) $pageNum = 1; } return array($pageNum, $pageNumPrefix); } /** * Attempt to match page path from PagePathHistory module * * @param string $path * @return bool * */ protected function getPathHistory($path) { if(!$this->options['useHistory']) return false; $module = $this->pagePathHistoryModule(); if(!$module) return false; $result = &$this->result; $info = $module->getPathInfo($path, array('allowUrlSegments' => true)); // if no history found return false if(!$info['id']) { $this->addMethod('pathHistory', false); return false; } // get page found in history $page = $this->pages->getOneById((int) $info['id'], array( 'template' => (int) $info['templates_id'], 'parent_id' => $info['parent_id'], 'autojoin' => false, )); if(!$page->id) { $this->addMethod('pathHistory', false, 'Found row but could not match to page'); return false; } $path = $page->path; $languageName = $this->languageName($info['language_id']); $keys = array('id', 'language_id', 'templates_id', 'parent_id', 'status', 'name'); foreach($keys as $key) { $result['page'][$key] = $info[$key]; } $result['language']['name'] = $languageName; $result['response'] = 301; $result['redirect'] = $path; $urlSegmentStr = $info['urlSegmentStr']; if(strlen($urlSegmentStr)) { $result['urlSegments'] = explode('/', $info['urlSegmentStr']); $this->applyResultPageNum($result['parts']); } $result = $this->finishResult($path); $this->addMethod('pathHistory', true); return true; } /*** UTILITIES ******************************************************************************/ /** * @var string * */ protected $pageNameCharset = ''; /** * Get page number segment with given pageNum and and in given language name * * @param int $pageNum * @param string $langName * @return string * */ protected function pageNumUrlSegment($pageNum, $langName = 'default') { $pageNum = (int) $pageNum; if($pageNum < 2) return ''; $a = $this->pageNumUrlPrefixes(); $prefix = isset($a[$langName]) ? $a[$langName] : $a['default']; return $prefix . $pageNum; } /** * Get pageNum URL prefixes indexed by language name * * @return array * */ protected function pageNumUrlPrefixes() { $config = $this->wire()->config; $a = $config->pageNumUrlPrefixes; if(!is_array($a)) $a = array(); $default = $config->pageNumUrlPrefix; if(empty($default)) $default = 'page'; if(empty($a['default'])) $a['default'] = $default; return $a; } /** * Does given path refer to homepage? * * @param string $path * @return bool * */ protected function isHomePath($path) { $path = trim($path, '/'); if($path === '') return true; $isHomePath = false; foreach($this->languageSegments() as $segment) { if($path !== $segment) continue; $isHomePath = true; break; } return $isHomePath; } /** * Convert ascii page name to UTF-8 * * @param string $name * @return string * */ protected function pageNameToUTF8($name) { $name = (string) $name; if($this->pageNameCharset !== 'UTF8') return $name; if(strpos($name, 'xn-') !== 0) return $name; return $this->wire()->sanitizer->pageName($name, Sanitizer::toUTF8); } /** * Convert UTF-8 page name to ascii * * @param string $name * @return string * */ protected function pageNameToAscii($name) { if($this->pageNameCharset !== 'UTF8') return $name; return $this->wire()->sanitizer->pageName($name, Sanitizer::toAscii); } /** * Get template used by page found in result or null if not yet known * * @return null|Template * */ protected function getResultTemplate() { if(!$this->template && !empty($this->result['page']['templates_id'])) { $this->template = $this->wire()->templates->get($this->result['page']['templates_id']); } return $this->template; } /** * Is matched result in admin? * * @return bool * */ protected function isResultInAdmin() { if($this->admin !== null) return $this->admin; $config = $this->wire()->config; if($this->result['page']['templates_id'] === 2) { $this->admin = true; } else if($this->result['page']['id'] === $config->adminRootPageID) { $this->admin = true; } else { $template = $this->getResultTemplate(); if(!$template) { return false; // may need to detect later } if(in_array($template->name, $config->adminTemplates, true)) { $this->admin = true; } else if(in_array($template->name, array('user', 'role', 'permission', 'language'))) { $this->admin = true; } else { $this->admin = false; } } return $this->admin; } /** * Get string length, using mb_strlen() if available, strlen() if not * * @param string $str * @return int * */ protected function strlen($str) { return function_exists('mb_strlen') ? mb_strlen($str) : strlen($str); } /** * Add method debug info (verbose mode) * * @param string $name * @param int|bool $code * @param string $note * */ protected function addMethod($name, $code, $note = '') { if(!$this->verbose) return; if($code === true) $code = 200; if($code === false) $code = 404; if(empty($note)) { if($code === 200) $note = 'OK'; if($code === 404) $note = 'Not found'; } if($note) $code = "$code $note"; $this->methods[] = "$name: $code"; } /** * Get homepage * * @return Page * */ protected function getHomepage() { return $this->pages->get((int) $this->wire()->config->rootPageID); } /** * Add named error message to result * * @param string $name * @param string $message * */ protected function addResultError($name, $message) { if(!$this->verbose) return; $this->result['errors'][$name] = $message; } /** * Add note to result * * @param string $message * */ protected function addResultNote($message) { if(!$this->verbose) return; $this->result['notes'][] = $message; } /** * Get default options * * #pw-internal * * @return array * */ public function getDefaultOptions() { return $this->defaults; } /*** MODULES **********************************************************************************/ /** * @var PagePaths|null * */ protected $pagePathsModule = null; /** * @var PagePathHistory|null * */ protected $pagePathHistoryModule = null; /** * @var null|PagesPathFinderTests * */ protected $tester = null; /** * Get optional PathPaths module instance if it is installed, false if not * * @return bool|PagePaths * */ protected function pagePathsModule() { if($this->pagePathsModule !== null) return $this->pagePathsModule; $modules = $this->wire()->modules; if($modules->isInstalled('PagePaths')) { $this->pagePathsModule = $modules->getModule('PagePaths'); } else { $this->pagePathsModule = false; } return $this->pagePathsModule; } /** * Get optional PagePathHistory module instance if it is installed, false if not * * @return PagePathHistory|bool * */ protected function pagePathHistoryModule() { if($this->pagePathHistoryModule !== null) return $this->pagePathHistoryModule; $modules = $this->wire()->modules; if($modules->isInstalled('PagePathHistory')) { $this->pagePathHistoryModule = $modules->getModule('PagePathHistory'); } else { $this->pagePathHistoryModule = false; } return $this->pagePathHistoryModule; } /** * Get PagesPathFinderTests instance * * #pw-internal * * @return PagesPathFinderTests * */ public function tester() { if($this->tester) return $this->tester; $this->tester = new PagesPathFinderTests(); $this->wire($this->tester); return $this->tester; } /*** LANGUAGES ******************************************************************************/ /** * Cache for languageSegments() method * * @var array * */ protected $languageSegments = array(); /** * Language names indexed by id * * @var array * */ protected $languageNames = array(); /** * Set result language by name or ID * * @param int|string|Language $language * @param string $segment * */ protected function setResultLanguage($language, $segment = '') { if(is_object($language)) { $name = $language->name; } else if(ctype_digit("$language")) { $id = (int) $language; $name = $this->languageName($id); } else { $name = $language; } $this->result['language']['name'] = $name; if($segment !== '') $this->setResultLanguageSegment($segment); } /** * Set result language segment * * @param string $segment * */ protected function setResultLanguageSegment($segment) { $this->result['language']['segment'] = $segment; } /** * Set result language status * * @param int|bool $status * */ protected function setResultLanguageStatus($status) { $this->result['language']['status'] = (int) $status; } /** * Get value from page status column * * @param int $pageId * @param int $languageId * @return int * */ protected function getPageLanguageStatus($pageId, $languageId) { $pageId = (int) $pageId; $languageId = (int) $languageId; $langName = $languageId ? $this->languageName($languageId) : 'default'; $col = $langName === 'default' ? 'status' : "status$languageId"; $page = $this->pages->cacher()->getCache((int) $pageId); if($page) return $page->get($col); $query = $this->wire()->database->prepare("SELECT `$col` FROM pages WHERE id=:id"); $query->bindValue(':id', $pageId, \PDO::PARAM_INT); try { $query->execute(); $status = (int) $query->fetchColumn(); $query->closeCursor(); } catch(\Exception $e) { $status = -1; } return $status; } /** * Return Languages if installed w/languageSupportPageNames module or blank array if not * * @param bool $getArray Force return value as an array indexed by language name * @return Languages|array * */ protected function languages($getArray = false) { if(is_array($this->useLanguages) && !count($this->useLanguages)) { return array(); } $languages = $this->wire()->languages; if(!$languages) { $this->useLanguages = array(); return array(); } if(!$languages->hasPageNames()) { $this->useLanguages = array(); return array(); } if($getArray) { $a = array(); foreach($languages as $language) { $a[$language->name] = $language; } $languages = $a; } return $languages; } /** * Given a value return corresponding language * * @param string|int|Language $value * @return Language|null * */ protected function language($value) { $language = null; if($value instanceof Page) { if($value->className() === 'Language' || wireInstanceOf($value, 'Language')) { $language = $value; } } else { /** @var Languages|array $languages */ $languages = $this->languages(); if(!count($languages)) return null; $id = $this->languageId($value); $language = $id ? $languages->get($id) : null; } if(!$language || !$language->id) return null; return $language; } /** * Get homepage name segments used for each language, indexed by language id * * #pw-internal * * @return array * */ public function languageSegments() { // use cached value when available if(count($this->languageSegments)) return $this->languageSegments; $columns = array(); $languages = $this->languages(); if(!count($languages)) return array(); foreach($languages as $language) { $name = $language->isDefault() ? "name" : "name$language->id"; $columns[$name] = $language->id; } // see if homepage already loaded in cache $homepage = $this->pages->cacher()->getCache(1); // if homepage available, get segments from it if($homepage) { foreach($columns as $name => $languageId) { $value = $homepage->get($name); if($name === 'name' && $value === Pages::defaultRootName) $value = ''; $this->languageSegments[$languageId] = $value; } } else { // if homepage not already loaded, pull segments directly from pages table $config = $this->wire()->config; $database = $this->wire()->database; $cols = implode(', ', array_keys($columns)); $sql = "SELECT $cols FROM pages WHERE id=:id"; $query = $database->prepare($sql); $query->bindValue(':id', (int) $config->rootPageID, \PDO::PARAM_INT); $query->execute(); $row = $query->fetch(\PDO::FETCH_ASSOC); $query->closeCursor(); foreach($row as $name => $value) { $languageId = $columns[$name]; $value = $this->pageNameToUTF8($value); if($name === 'name' && $value === Pages::defaultRootName) $value = ''; $this->languageSegments[$languageId] = $value; } } return $this->languageSegments; } /** * Is given segment a language segment? Returns language ID if yes, false if no * * #pw-internal * * @param string $segment * @return false|int * */ public function isLanguageSegment($segment) { $key = array_search($segment, $this->languageSegments()); return $key; } /** * Return homepage name segment used by given language * * @param string|int|Language $language * @return string * */ protected function languageSegment($language) { $id = $this->languageId($language); $segments = $this->languageSegments(); return isset($segments[$id]) ? $segments[$id] : ''; } /** * Return language identified by homepage name segment * * @param string $segment * @param bool $getLanguageId * @return Language|null|int * */ protected function segmentLanguage($segment, $getLanguageId = false) { $segments = $this->languageSegments(); $languageId = array_search($segment, $segments); if($getLanguageId) return $languageId ? $languageId : 0; return $languageId ? $this->language($languageId) : null; } /** * Return name for given language id or object * * @param int|string|Language $language * @return string * */ protected function languageName($language) { $names = $this->languageNames(); $languageId = $this->languageId($language); if(isset($names[$languageId])) return $names[$languageId]; return 'default'; } /** * Return all language names indexed by language id * * @return array * */ protected function languageNames() { if(empty($this->languageNames)) { foreach($this->languages() as $lang) { $this->languageNames[$lang->id] = $lang->name; } } return $this->languageNames; } /** * Return language id for given value (language name, name column or home segment) * * @param int|string|Language $language * @return int * */ protected function languageId($language) { $language = (string) $language; if(ctype_digit("$language")) return (int) "$language"; // object or number string if($language === 'name' || $language === 'default') { // name property translates to default language $languages = $this->languages(); return count($languages) ? $languages->getDefault()->id : 0; } else if(strpos($language, 'name') === 0 && ctype_digit(substr($language, 5))) { // i.e. name1234 where 1234 is language id $language = str_replace('name', '', $language); } else if(!ctype_digit($language)) { // likely a language name $langName = $language; $language = null; foreach($this->languages() as $lang) { if($lang->name === $langName) $language = $lang; if($language) break; } if(!$language) { // if not found by language name, try home language segments $languageId = 0; foreach($this->languageSegments() as $id => $segment) { if($segment === $langName) $languageId = (int) $id; if($languageId) break; } if($languageId) return $languageId; } $language = $language ? $language->id : 0; } return (int) $language; } /** * Update given path for result language and return it * * @param string $path * @param string $langName * @return string * */ protected function updatePathForLanguage($path, $langName = '') { $result = &$this->result; $config = $this->wire()->config; $template = $this->getResultTemplate(); if($template && in_array($template->name, $config->adminTemplates)) { return $this->removeLanguageSegment($path); } if(empty($langName)) $langName = $result['language']['name']; if(empty($langName)) $langName = 'default'; if($langName === 'default' && ($result['page']['id'] === 1 || $path === '/')) { $pageNames = $this->wire()->languages->pageNames(); if(!$pageNames) return $path; $useSegment = $pageNames->useHomeSegment; } else { $useSegment = true; } if($useSegment) { $path = $this->addLanguageSegment($path, $langName); } else { $path = $this->removeLanguageSegment($path); } return $path; } /** * Add language segment * * @param string $path * @param string|Language|int $language * @return string * */ protected function addLanguageSegment($path, $language) { if(strpos($path, '/') !== 0) $path = "/$path"; $segment = $this->languageSegment($language); if(!strlen($segment)) return $path; if($path === "/$segment" && $this->result['page']['id'] < 2) return $path; if(strpos($path, "/$segment/") === 0) return $path; return "/$segment$path"; } /** * Remove any language segments present on given path * * @param string $path * @return string * */ protected function removeLanguageSegment($path) { if(strpos($path, '/') !== 0) $path = "/$path"; if($path === '/') return $path; $segments = $this->languageSegments(); $segments[] = Pages::defaultRootName; foreach($segments as $segment) { if($segment === null || !strlen($segment)) continue; if($path !== "/$segment" && strpos($path, "/$segment/") !== 0) continue; list(,$path) = explode("/$segment", $path, 2); if($path === '') $path = '/'; break; } return $path; } /*** ADDITIONAL/HELPER LOGIC ****************************************************************/ /** * Update result for cases where a redirect was determined that involved pagination * * Most of the logic here allows for the special case of admin URLs, which work with either * a custom pageNumUrlPrefix or the original/default one. This is a helper for the * finishResult() method. * * @param int $response * @var array $result * @return int * @since 3.0.198 * */ protected function finishResultRedirectPageNum($response, &$result) { if($result['pageNum'] < 2) return $response; if(empty($result['page']['templates_id'])) return $response; if($result['page']['status'] >= Page::statusUnpublished) return $response; // The config[_pageNumUrlPrefix] property is set by LanguageSupportPageNames $pageNumUrlPrefix = $this->wire()->config->get('_pageNumUrlPrefix'); if(empty($pageNumUrlPrefix)) $pageNumUrlPrefix = 'page'; // if default/original pageNum prefix not in use then do nothing further if($result['pageNumPrefix'] !== $pageNumUrlPrefix) return $response; // if request is not for something in the admin then do nothing further $adminTemplate = $this->wire()->templates->get('admin'); if(!$adminTemplate || $result['page']['templates_id'] != $adminTemplate->id) return $response; // request is for pagination within admin, where we allow either custom or original/default prefix $requestParts = explode('/', trim($result['request'], '/')); $redirectParts = explode('/', trim($result['redirect'], '/')); $requestPrefix = array_pop($requestParts); $redirectPrefix = array_pop($redirectParts); $requestPath = implode('/', $requestParts); $redirectPath = implode('/', $redirectParts); // if something other than pagination prefix differs then do nothing further if($requestPath != $redirectPath || $requestPrefix === $redirectPrefix) return $response; // only the pagination prefix differs, allow it when in admin $result['notes'][] = "Default pagination prefix '$pageNumUrlPrefix' allowed for admin template"; $response = 200; return $response; } } /** * PagesPathFinder Tests * * Usage: * ~~~~~ * $tester = $pages->pathFinder()->tester(); * $a = $tester->testPath('/path/to/page/'); * $a = $tester->testPage(Page $page); * $a = $tester->testPages("has_parent!=2"); * $a = $tester->testPages(PageArray $items); * ~~~~~ * */ class PagesPathFinderTests extends Wire { /** * @return PagesPathFinder * */ public function pathFinder() { return $this->wire()->pages->pathFinder(); } /** * @param string $path * @param int $expectResponse * @return array * */ public function testPath($path, $expectResponse = 0) { $tests = array(); $testResults = array(); $results = array(); $optionSets = array( 'defaults' => $this->pathFinder()->getDefaultOptions(), 'noPagePaths' => array('usePagePaths' => false), 'noGlobalUnique' => array('useGlobalUnique' => false), 'noHistory' => array('useHistory' => false), 'excludeRoot' => array('useExcludeRoot' => true), ); foreach($optionSets as $name => $options) { $options['test'] = true; $result = $this->pathFinder()->get($path, $options); $test = $result['test']; $results[$name] = $result; $tests[$name] = $test; } $defaultTest = $tests['defaults']; foreach(array_keys($optionSets) as $name) { $test = $tests[$name]; $result = $results[$name]; if($expectResponse && $result['response'] != $expectResponse) { $status = "FAIL ($result[response] != $expectResponse)"; } else { $status = ($test === $defaultTest ? 'OK' : 'FAIL'); } $testResults[] = array( 'name' => $name, 'status' => $status, 'test' => $test ); } return $testResults; } /** * @param Page $item * @return array * */ public function testPage(Page $item) { $languages = $this->languages(); $testResults = array(); $defaultPath = $item->path(); if($languages) { foreach($languages as $language) { // $user->setLanguage($language); $path = $item->localPath($language); if($language->isDefault() || $path === $defaultPath) { $expect = 200; } else { $expect = $item->get("status$language") > 0 ? 200 : 300; } $testResults["$language->name:$path"] = $this->testPath($path, $expect); } } else { $path = $item->path(); $testResults[$path] = $this->testPath($path, 200); } return $testResults; } /** * @param string|PageArray $selector * @return array * */ public function testPages($selector) { if($selector instanceof PageArray) { $items = $selector; } else { $items = $this->pages->find($selector); } $testResults = array(); foreach($items as $item) { $testResults = array_merge($testResults, $this->testPage($item)); } return $testResults; } }