Pages.php 69 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246
  1. <?php
  2. /**
  3. * @package Grav\Common\Page
  4. *
  5. * @copyright Copyright (c) 2015 - 2023 Trilby Media, LLC. All rights reserved.
  6. * @license MIT License; see LICENSE file for details.
  7. */
  8. namespace Grav\Common\Page;
  9. use Exception;
  10. use FilesystemIterator;
  11. use Grav\Common\Cache;
  12. use Grav\Common\Config\Config;
  13. use Grav\Common\Data\Blueprint;
  14. use Grav\Common\Data\Blueprints;
  15. use Grav\Common\Debugger;
  16. use Grav\Common\Filesystem\Folder;
  17. use Grav\Common\Flex\Types\Pages\PageCollection;
  18. use Grav\Common\Flex\Types\Pages\PageIndex;
  19. use Grav\Common\Grav;
  20. use Grav\Common\Language\Language;
  21. use Grav\Common\Page\Interfaces\PageCollectionInterface;
  22. use Grav\Common\Page\Interfaces\PageInterface;
  23. use Grav\Common\Taxonomy;
  24. use Grav\Common\Uri;
  25. use Grav\Common\Utils;
  26. use Grav\Events\TypesEvent;
  27. use Grav\Framework\Flex\Flex;
  28. use Grav\Framework\Flex\FlexDirectory;
  29. use Grav\Framework\Flex\Interfaces\FlexTranslateInterface;
  30. use Grav\Framework\Flex\Pages\FlexPageObject;
  31. use Grav\Plugin\Admin;
  32. use RocketTheme\Toolbox\Event\Event;
  33. use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
  34. use RuntimeException;
  35. use SplFileInfo;
  36. use Symfony\Component\EventDispatcher\EventDispatcher;
  37. use Whoops\Exception\ErrorException;
  38. use Collator;
  39. use function array_key_exists;
  40. use function array_search;
  41. use function count;
  42. use function dirname;
  43. use function extension_loaded;
  44. use function in_array;
  45. use function is_array;
  46. use function is_int;
  47. use function is_string;
  48. /**
  49. * Class Pages
  50. * @package Grav\Common\Page
  51. */
  52. class Pages
  53. {
  54. /** @var FlexDirectory|null */
  55. private $directory;
  56. /** @var Grav */
  57. protected $grav;
  58. /** @var array<PageInterface> */
  59. protected $instances = [];
  60. /** @var array<PageInterface|string> */
  61. protected $index = [];
  62. /** @var array */
  63. protected $children;
  64. /** @var string */
  65. protected $base = '';
  66. /** @var string[] */
  67. protected $baseRoute = [];
  68. /** @var string[] */
  69. protected $routes = [];
  70. /** @var array */
  71. protected $sort;
  72. /** @var Blueprints */
  73. protected $blueprints;
  74. /** @var bool */
  75. protected $enable_pages = true;
  76. /** @var int */
  77. protected $last_modified;
  78. /** @var string[] */
  79. protected $ignore_files;
  80. /** @var string[] */
  81. protected $ignore_folders;
  82. /** @var bool */
  83. protected $ignore_hidden;
  84. /** @var string */
  85. protected $check_method;
  86. /** @var string */
  87. protected $simple_pages_hash;
  88. /** @var string */
  89. protected $pages_cache_id;
  90. /** @var bool */
  91. protected $initialized = false;
  92. /** @var string */
  93. protected $active_lang;
  94. /** @var bool */
  95. protected $fire_events = false;
  96. /** @var Types|null */
  97. protected static $types;
  98. /** @var string|null */
  99. protected static $home_route;
  100. /**
  101. * Constructor
  102. *
  103. * @param Grav $grav
  104. */
  105. public function __construct(Grav $grav)
  106. {
  107. $this->grav = $grav;
  108. }
  109. /**
  110. * @return FlexDirectory|null
  111. */
  112. public function getDirectory(): ?FlexDirectory
  113. {
  114. return $this->directory;
  115. }
  116. /**
  117. * Method used in admin to disable frontend pages from being initialized.
  118. */
  119. public function disablePages(): void
  120. {
  121. $this->enable_pages = false;
  122. }
  123. /**
  124. * Method used in admin to later load frontend pages.
  125. */
  126. public function enablePages(): void
  127. {
  128. if (!$this->enable_pages) {
  129. $this->enable_pages = true;
  130. $this->init();
  131. }
  132. }
  133. /**
  134. * Get or set base path for the pages.
  135. *
  136. * @param string|null $path
  137. * @return string
  138. */
  139. public function base($path = null)
  140. {
  141. if ($path !== null) {
  142. $path = trim($path, '/');
  143. $this->base = $path ? '/' . $path : '';
  144. $this->baseRoute = [];
  145. }
  146. return $this->base;
  147. }
  148. /**
  149. *
  150. * Get base route for Grav pages.
  151. *
  152. * @param string|null $lang Optional language code for multilingual routes.
  153. * @return string
  154. */
  155. public function baseRoute($lang = null)
  156. {
  157. $key = $lang ?: $this->active_lang ?: 'default';
  158. if (!isset($this->baseRoute[$key])) {
  159. /** @var Language $language */
  160. $language = $this->grav['language'];
  161. $path_base = rtrim($this->base(), '/');
  162. $path_lang = $language->enabled() ? $language->getLanguageURLPrefix($lang) : '';
  163. $this->baseRoute[$key] = $path_base . $path_lang;
  164. }
  165. return $this->baseRoute[$key];
  166. }
  167. /**
  168. *
  169. * Get route for Grav site.
  170. *
  171. * @param string $route Optional route to the page.
  172. * @param string|null $lang Optional language code for multilingual links.
  173. * @return string
  174. */
  175. public function route($route = '/', $lang = null)
  176. {
  177. if (!$route || $route === '/') {
  178. return $this->baseRoute($lang) ?: '/';
  179. }
  180. return $this->baseRoute($lang) . $route;
  181. }
  182. /**
  183. * Get relative referrer route and language code. Returns null if the route isn't within the current base, language (if set) and route.
  184. *
  185. * @example `$langCode = null; $referrer = $pages->referrerRoute($langCode, '/admin');` returns relative referrer url within /admin and updates $langCode
  186. * @example `$langCode = 'en'; $referrer = $pages->referrerRoute($langCode, '/admin');` returns relative referrer url within the /en/admin
  187. *
  188. * @param string|null $langCode Variable to store the language code. If already set, check only against that language.
  189. * @param string $route Optional route within the site.
  190. * @return string|null
  191. * @since 1.7.23
  192. */
  193. public function referrerRoute(?string &$langCode, string $route = '/'): ?string
  194. {
  195. $referrer = $_SERVER['HTTP_REFERER'] ?? null;
  196. // Start by checking that referrer came from our site.
  197. $root = $this->grav['base_url_absolute'];
  198. if (!is_string($referrer) || !str_starts_with($referrer, $root)) {
  199. return null;
  200. }
  201. /** @var Language $language */
  202. $language = $this->grav['language'];
  203. // Get all language codes and append no language.
  204. if (null === $langCode) {
  205. $languages = $language->enabled() ? $language->getLanguages() : [];
  206. $languages[] = '';
  207. } else {
  208. $languages[] = $langCode;
  209. }
  210. $path_base = rtrim($this->base(), '/');
  211. $path_route = rtrim($route, '/');
  212. // Try to figure out the language code.
  213. foreach ($languages as $code) {
  214. $path_lang = $code ? "/{$code}" : '';
  215. $base = $path_base . $path_lang . $path_route;
  216. if ($referrer === $base || str_starts_with($referrer, "{$base}/")) {
  217. if (null === $langCode) {
  218. $langCode = $code;
  219. }
  220. return substr($referrer, \strlen($base));
  221. }
  222. }
  223. return null;
  224. }
  225. /**
  226. *
  227. * Get base URL for Grav pages.
  228. *
  229. * @param string|null $lang Optional language code for multilingual links.
  230. * @param bool|null $absolute If true, return absolute url, if false, return relative url. Otherwise return default.
  231. * @return string
  232. */
  233. public function baseUrl($lang = null, $absolute = null)
  234. {
  235. if ($absolute === null) {
  236. $type = 'base_url';
  237. } elseif ($absolute) {
  238. $type = 'base_url_absolute';
  239. } else {
  240. $type = 'base_url_relative';
  241. }
  242. return $this->grav[$type] . $this->baseRoute($lang);
  243. }
  244. /**
  245. *
  246. * Get home URL for Grav site.
  247. *
  248. * @param string|null $lang Optional language code for multilingual links.
  249. * @param bool|null $absolute If true, return absolute url, if false, return relative url. Otherwise return default.
  250. * @return string
  251. */
  252. public function homeUrl($lang = null, $absolute = null)
  253. {
  254. return $this->baseUrl($lang, $absolute) ?: '/';
  255. }
  256. /**
  257. *
  258. * Get URL for Grav site.
  259. *
  260. * @param string $route Optional route to the page.
  261. * @param string|null $lang Optional language code for multilingual links.
  262. * @param bool|null $absolute If true, return absolute url, if false, return relative url. Otherwise return default.
  263. * @return string
  264. */
  265. public function url($route = '/', $lang = null, $absolute = null)
  266. {
  267. if (!$route || $route === '/') {
  268. return $this->homeUrl($lang, $absolute);
  269. }
  270. return $this->baseUrl($lang, $absolute) . Uri::filterPath($route);
  271. }
  272. /**
  273. * @param string $method
  274. * @return void
  275. */
  276. public function setCheckMethod($method): void
  277. {
  278. $this->check_method = strtolower($method);
  279. }
  280. /**
  281. * @return void
  282. */
  283. public function register(): void
  284. {
  285. $config = $this->grav['config'];
  286. $type = $config->get('system.pages.type');
  287. if ($type === 'flex') {
  288. $this->initFlexPages();
  289. }
  290. }
  291. /**
  292. * Reset pages (used in search indexing etc).
  293. *
  294. * @return void
  295. */
  296. public function reset(): void
  297. {
  298. $this->initialized = false;
  299. $this->init();
  300. }
  301. /**
  302. * Class initialization. Must be called before using this class.
  303. */
  304. public function init(): void
  305. {
  306. if ($this->initialized) {
  307. return;
  308. }
  309. $config = $this->grav['config'];
  310. $this->ignore_files = (array)$config->get('system.pages.ignore_files');
  311. $this->ignore_folders = (array)$config->get('system.pages.ignore_folders');
  312. $this->ignore_hidden = (bool)$config->get('system.pages.ignore_hidden');
  313. $this->fire_events = (bool)$config->get('system.pages.events.page');
  314. $this->instances = [];
  315. $this->index = [];
  316. $this->children = [];
  317. $this->routes = [];
  318. if (!$this->check_method) {
  319. $this->setCheckMethod($config->get('system.cache.check.method', 'file'));
  320. }
  321. if ($this->enable_pages === false) {
  322. $page = $this->buildRootPage();
  323. $this->instances[$page->path()] = $page;
  324. return;
  325. }
  326. $this->buildPages();
  327. $this->initialized = true;
  328. }
  329. /**
  330. * Get or set last modification time.
  331. *
  332. * @param int|null $modified
  333. * @return int|null
  334. */
  335. public function lastModified($modified = null)
  336. {
  337. if ($modified && $modified > $this->last_modified) {
  338. $this->last_modified = $modified;
  339. }
  340. return $this->last_modified;
  341. }
  342. /**
  343. * Returns a list of all pages.
  344. *
  345. * @return PageInterface[]
  346. */
  347. public function instances()
  348. {
  349. $instances = [];
  350. foreach ($this->index as $path => $instance) {
  351. $page = $this->get($path);
  352. if ($page) {
  353. $instances[$path] = $page;
  354. }
  355. }
  356. return $instances;
  357. }
  358. /**
  359. * Returns a list of all routes.
  360. *
  361. * @return array
  362. */
  363. public function routes()
  364. {
  365. return $this->routes;
  366. }
  367. /**
  368. * Adds a page and assigns a route to it.
  369. *
  370. * @param PageInterface $page Page to be added.
  371. * @param string|null $route Optional route (uses route from the object if not set).
  372. */
  373. public function addPage(PageInterface $page, $route = null): void
  374. {
  375. $path = $page->path() ?? '';
  376. if (!isset($this->index[$path])) {
  377. $this->index[$path] = $page;
  378. $this->instances[$path] = $page;
  379. }
  380. $route = $page->route($route);
  381. $parent = $page->parent();
  382. if ($parent) {
  383. $this->children[$parent->path() ?? ''][$path] = ['slug' => $page->slug()];
  384. }
  385. $this->routes[$route] = $path;
  386. $this->grav->fireEvent('onPageProcessed', new Event(['page' => $page]));
  387. }
  388. /**
  389. * Get a collection of pages in the given context.
  390. *
  391. * @param array $params
  392. * @param array $context
  393. * @return PageCollectionInterface|Collection
  394. */
  395. public function getCollection(array $params = [], array $context = [])
  396. {
  397. if (!isset($params['items'])) {
  398. return new Collection();
  399. }
  400. /** @var Config $config */
  401. $config = $this->grav['config'];
  402. $context += [
  403. 'event' => true,
  404. 'pagination' => true,
  405. 'url_taxonomy_filters' => $config->get('system.pages.url_taxonomy_filters'),
  406. 'taxonomies' => (array)$config->get('site.taxonomies'),
  407. 'pagination_page' => 1,
  408. 'self' => null,
  409. ];
  410. // Include taxonomies from the URL if requested.
  411. $process_taxonomy = $params['url_taxonomy_filters'] ?? $context['url_taxonomy_filters'];
  412. if ($process_taxonomy) {
  413. /** @var Uri $uri */
  414. $uri = $this->grav['uri'];
  415. foreach ($context['taxonomies'] as $taxonomy) {
  416. $param = $uri->param(rawurlencode($taxonomy));
  417. $items = is_string($param) ? explode(',', $param) : [];
  418. foreach ($items as $item) {
  419. $params['taxonomies'][$taxonomy][] = htmlspecialchars_decode(rawurldecode($item), ENT_QUOTES);
  420. }
  421. }
  422. }
  423. $pagination = $params['pagination'] ?? $context['pagination'];
  424. if ($pagination && !isset($params['page'], $params['start'])) {
  425. /** @var Uri $uri */
  426. $uri = $this->grav['uri'];
  427. $context['current_page'] = $uri->currentPage();
  428. }
  429. $collection = $this->evaluate($params['items'], $context['self']);
  430. $collection->setParams($params);
  431. // Filter by taxonomies.
  432. foreach ($params['taxonomies'] ?? [] as $taxonomy => $items) {
  433. foreach ($collection as $page) {
  434. // Don't include modules
  435. if ($page->isModule()) {
  436. continue;
  437. }
  438. $test = $page->taxonomy()[$taxonomy] ?? [];
  439. foreach ($items as $item) {
  440. if (!$test || !in_array($item, $test, true)) {
  441. $collection->remove($page->path());
  442. }
  443. }
  444. }
  445. }
  446. $filters = $params['filter'] ?? [];
  447. // Assume published=true if not set.
  448. if (!isset($filters['published']) && !isset($filters['non-published'])) {
  449. $filters['published'] = true;
  450. }
  451. // Remove any inclusive sets from filter.
  452. $sets = ['published', 'visible', 'modular', 'routable'];
  453. foreach ($sets as $type) {
  454. $nonType = "non-{$type}";
  455. if (isset($filters[$type], $filters[$nonType]) && $filters[$type] === $filters[$nonType]) {
  456. if (!$filters[$type]) {
  457. // Both options are false, return empty collection as nothing can match the filters.
  458. return new Collection();
  459. }
  460. // Both options are true, remove opposite filters as all pages will match the filters.
  461. unset($filters[$type], $filters[$nonType]);
  462. }
  463. }
  464. // Filter the collection
  465. foreach ($filters as $type => $filter) {
  466. if (null === $filter) {
  467. continue;
  468. }
  469. // Convert non-type to type.
  470. if (str_starts_with($type, 'non-')) {
  471. $type = substr($type, 4);
  472. $filter = !$filter;
  473. }
  474. switch ($type) {
  475. case 'translated':
  476. if ($filter) {
  477. $collection = $collection->translated();
  478. } else {
  479. $collection = $collection->nonTranslated();
  480. }
  481. break;
  482. case 'published':
  483. if ($filter) {
  484. $collection = $collection->published();
  485. } else {
  486. $collection = $collection->nonPublished();
  487. }
  488. break;
  489. case 'visible':
  490. if ($filter) {
  491. $collection = $collection->visible();
  492. } else {
  493. $collection = $collection->nonVisible();
  494. }
  495. break;
  496. case 'page':
  497. if ($filter) {
  498. $collection = $collection->pages();
  499. } else {
  500. $collection = $collection->modules();
  501. }
  502. break;
  503. case 'module':
  504. case 'modular':
  505. if ($filter) {
  506. $collection = $collection->modules();
  507. } else {
  508. $collection = $collection->pages();
  509. }
  510. break;
  511. case 'routable':
  512. if ($filter) {
  513. $collection = $collection->routable();
  514. } else {
  515. $collection = $collection->nonRoutable();
  516. }
  517. break;
  518. case 'type':
  519. $collection = $collection->ofType($filter);
  520. break;
  521. case 'types':
  522. $collection = $collection->ofOneOfTheseTypes($filter);
  523. break;
  524. case 'access':
  525. $collection = $collection->ofOneOfTheseAccessLevels($filter);
  526. break;
  527. }
  528. }
  529. if (isset($params['dateRange'])) {
  530. $start = $params['dateRange']['start'] ?? null;
  531. $end = $params['dateRange']['end'] ?? null;
  532. $field = $params['dateRange']['field'] ?? null;
  533. $collection = $collection->dateRange($start, $end, $field);
  534. }
  535. if (isset($params['order'])) {
  536. $by = $params['order']['by'] ?? 'default';
  537. $dir = $params['order']['dir'] ?? 'asc';
  538. $custom = $params['order']['custom'] ?? null;
  539. $sort_flags = $params['order']['sort_flags'] ?? null;
  540. if (is_array($sort_flags)) {
  541. $sort_flags = array_map('constant', $sort_flags); //transform strings to constant value
  542. $sort_flags = array_reduce($sort_flags, static function ($a, $b) {
  543. return $a | $b;
  544. }, 0); //merge constant values using bit or
  545. }
  546. $collection = $collection->order($by, $dir, $custom, $sort_flags);
  547. }
  548. // New Custom event to handle things like pagination.
  549. if ($context['event']) {
  550. $this->grav->fireEvent('onCollectionProcessed', new Event(['collection' => $collection, 'context' => $context]));
  551. }
  552. if ($context['pagination']) {
  553. // Slice and dice the collection if pagination is required
  554. $params = $collection->params();
  555. $limit = (int)($params['limit'] ?? 0);
  556. $page = (int)($params['page'] ?? $context['current_page'] ?? 0);
  557. $start = (int)($params['start'] ?? 0);
  558. $start = $limit > 0 && $page > 0 ? ($page - 1) * $limit : max(0, $start);
  559. if ($start || ($limit && $collection->count() > $limit)) {
  560. $collection->slice($start, $limit ?: null);
  561. }
  562. }
  563. return $collection;
  564. }
  565. /**
  566. * @param array|string $value
  567. * @param PageInterface|null $self
  568. * @return Collection
  569. */
  570. protected function evaluate($value, PageInterface $self = null)
  571. {
  572. // Parse command.
  573. if (is_string($value)) {
  574. // Format: @command.param
  575. $cmd = $value;
  576. $params = [];
  577. } elseif (is_array($value) && count($value) === 1 && !is_int(key($value))) {
  578. // Format: @command.param: { attr1: value1, attr2: value2 }
  579. $cmd = (string)key($value);
  580. $params = (array)current($value);
  581. } else {
  582. $result = [];
  583. foreach ((array)$value as $key => $val) {
  584. if (is_int($key)) {
  585. $result = $result + $this->evaluate($val, $self)->toArray();
  586. } else {
  587. $result = $result + $this->evaluate([$key => $val], $self)->toArray();
  588. }
  589. }
  590. return new Collection($result);
  591. }
  592. $parts = explode('.', $cmd);
  593. $scope = array_shift($parts);
  594. $type = $parts[0] ?? null;
  595. /** @var PageInterface|null $page */
  596. $page = null;
  597. switch ($scope) {
  598. case 'self@':
  599. case '@self':
  600. $page = $self;
  601. break;
  602. case 'page@':
  603. case '@page':
  604. $page = isset($params[0]) ? $this->find($params[0]) : null;
  605. break;
  606. case 'root@':
  607. case '@root':
  608. $page = $this->root();
  609. break;
  610. case 'taxonomy@':
  611. case '@taxonomy':
  612. // Gets a collection of pages by using one of the following formats:
  613. // @taxonomy.category: blog
  614. // @taxonomy.category: [ blog, featured ]
  615. // @taxonomy: { category: [ blog, featured ], level: 1 }
  616. /** @var Taxonomy $taxonomy_map */
  617. $taxonomy_map = Grav::instance()['taxonomy'];
  618. if (!empty($parts)) {
  619. $params = [implode('.', $parts) => $params];
  620. }
  621. return $taxonomy_map->findTaxonomy($params);
  622. }
  623. if (!$page) {
  624. return new Collection();
  625. }
  626. // Handle '@page', '@page.modular: false', '@self' and '@self.modular: false'.
  627. if (null === $type || (in_array($type, ['modular', 'modules']) && ($params[0] ?? null) === false)) {
  628. $type = 'children';
  629. }
  630. switch ($type) {
  631. case 'all':
  632. $collection = $page->children();
  633. break;
  634. case 'modules':
  635. case 'modular':
  636. $collection = $page->children()->modules();
  637. break;
  638. case 'pages':
  639. case 'children':
  640. $collection = $page->children()->pages();
  641. break;
  642. case 'page':
  643. case 'self':
  644. $collection = !$page->root() ? (new Collection())->addPage($page) : new Collection();
  645. break;
  646. case 'parent':
  647. $parent = $page->parent();
  648. $collection = new Collection();
  649. $collection = $parent ? $collection->addPage($parent) : $collection;
  650. break;
  651. case 'siblings':
  652. $parent = $page->parent();
  653. if ($parent) {
  654. /** @var Collection $collection */
  655. $collection = $parent->children();
  656. $collection = $collection->remove($page->path());
  657. } else {
  658. $collection = new Collection();
  659. }
  660. break;
  661. case 'descendants':
  662. $collection = $this->all($page)->remove($page->path())->pages();
  663. break;
  664. default:
  665. // Unknown type; return empty collection.
  666. $collection = new Collection();
  667. break;
  668. }
  669. return $collection;
  670. }
  671. /**
  672. * Sort sub-pages in a page.
  673. *
  674. * @param PageInterface $page
  675. * @param string|null $order_by
  676. * @param string|null $order_dir
  677. * @return array
  678. */
  679. public function sort(PageInterface $page, $order_by = null, $order_dir = null, $sort_flags = null)
  680. {
  681. if ($order_by === null) {
  682. $order_by = $page->orderBy();
  683. }
  684. if ($order_dir === null) {
  685. $order_dir = $page->orderDir();
  686. }
  687. $path = $page->path();
  688. if (null === $path) {
  689. return [];
  690. }
  691. $children = $this->children[$path] ?? [];
  692. if (!$children) {
  693. return $children;
  694. }
  695. if (!isset($this->sort[$path][$order_by])) {
  696. $this->buildSort($path, $children, $order_by, $page->orderManual(), $sort_flags);
  697. }
  698. $sort = $this->sort[$path][$order_by];
  699. if ($order_dir !== 'asc') {
  700. $sort = array_reverse($sort);
  701. }
  702. return $sort;
  703. }
  704. /**
  705. * @param Collection $collection
  706. * @param string $orderBy
  707. * @param string $orderDir
  708. * @param array|null $orderManual
  709. * @param int|null $sort_flags
  710. * @return array
  711. * @internal
  712. */
  713. public function sortCollection(Collection $collection, $orderBy, $orderDir = 'asc', $orderManual = null, $sort_flags = null)
  714. {
  715. $items = $collection->toArray();
  716. if (!$items) {
  717. return [];
  718. }
  719. $lookup = md5(json_encode($items) . json_encode($orderManual) . $orderBy . $orderDir);
  720. if (!isset($this->sort[$lookup][$orderBy])) {
  721. $this->buildSort($lookup, $items, $orderBy, $orderManual, $sort_flags);
  722. }
  723. $sort = $this->sort[$lookup][$orderBy];
  724. if ($orderDir !== 'asc') {
  725. $sort = array_reverse($sort);
  726. }
  727. return $sort;
  728. }
  729. /**
  730. * Get a page instance.
  731. *
  732. * @param string $path The filesystem full path of the page
  733. * @return PageInterface|null
  734. * @throws RuntimeException
  735. */
  736. public function get($path)
  737. {
  738. $path = (string)$path;
  739. if ($path === '') {
  740. return null;
  741. }
  742. // Check for local instances first.
  743. if (array_key_exists($path, $this->instances)) {
  744. return $this->instances[$path];
  745. }
  746. $instance = $this->index[$path] ?? null;
  747. if (is_string($instance)) {
  748. if ($this->directory) {
  749. /** @var Language $language */
  750. $language = $this->grav['language'];
  751. $lang = $language->getActive();
  752. if ($lang) {
  753. $languages = $language->getFallbackLanguages($lang, true);
  754. $key = $instance;
  755. $instance = null;
  756. foreach ($languages as $code) {
  757. $test = $code ? $key . ':' . $code : $key;
  758. if (($instance = $this->directory->getObject($test, 'flex_key')) !== null) {
  759. break;
  760. }
  761. }
  762. } else {
  763. $instance = $this->directory->getObject($instance, 'flex_key');
  764. }
  765. }
  766. if ($instance instanceof PageInterface) {
  767. if ($this->fire_events && method_exists($instance, 'initialize')) {
  768. $instance->initialize();
  769. }
  770. } else {
  771. /** @var Debugger $debugger */
  772. $debugger = $this->grav['debugger'];
  773. $debugger->addMessage(sprintf('Flex page %s is missing or broken!', $instance), 'debug');
  774. }
  775. }
  776. if ($instance) {
  777. $this->instances[$path] = $instance;
  778. }
  779. return $instance;
  780. }
  781. /**
  782. * Get children of the path.
  783. *
  784. * @param string $path
  785. * @return Collection
  786. */
  787. public function children($path)
  788. {
  789. $children = $this->children[(string)$path] ?? [];
  790. return new Collection($children, [], $this);
  791. }
  792. /**
  793. * Get a page ancestor.
  794. *
  795. * @param string $route The relative URL of the page
  796. * @param string|null $path The relative path of the ancestor folder
  797. * @return PageInterface|null
  798. */
  799. public function ancestor($route, $path = null)
  800. {
  801. if ($path !== null) {
  802. $page = $this->find($route, true);
  803. if ($page && $page->path() === $path) {
  804. return $page;
  805. }
  806. $parent = $page ? $page->parent() : null;
  807. if ($parent && !$parent->root()) {
  808. return $this->ancestor($parent->route(), $path);
  809. }
  810. }
  811. return null;
  812. }
  813. /**
  814. * Get a page ancestor trait.
  815. *
  816. * @param string $route The relative route of the page
  817. * @param string|null $field The field name of the ancestor to query for
  818. * @return PageInterface|null
  819. */
  820. public function inherited($route, $field = null)
  821. {
  822. if ($field !== null) {
  823. $page = $this->find($route, true);
  824. $parent = $page ? $page->parent() : null;
  825. if ($parent && $parent->value('header.' . $field) !== null) {
  826. return $parent;
  827. }
  828. if ($parent && !$parent->root()) {
  829. return $this->inherited($parent->route(), $field);
  830. }
  831. }
  832. return null;
  833. }
  834. /**
  835. * Find a page based on route.
  836. *
  837. * @param string $route The route of the page
  838. * @param bool $all If true, return also non-routable pages, otherwise return null if page isn't routable
  839. * @return PageInterface|null
  840. */
  841. public function find($route, $all = false)
  842. {
  843. $route = urldecode((string)$route);
  844. // Fetch page if there's a defined route to it.
  845. $path = $this->routes[$route] ?? null;
  846. $page = null !== $path ? $this->get($path) : null;
  847. // Try without trailing slash
  848. if (null === $page && Utils::endsWith($route, '/')) {
  849. $path = $this->routes[rtrim($route, '/')] ?? null;
  850. $page = null !== $path ? $this->get($path) : null;
  851. }
  852. if (!$all && !isset($this->grav['admin'])) {
  853. if (null === $page || !$page->routable()) {
  854. // If the page cannot be accessed, look for the site wide routes and wildcards.
  855. $page = $this->findSiteBasedRoute($route) ?? $page;
  856. }
  857. }
  858. return $page;
  859. }
  860. /**
  861. * Check site based routes.
  862. *
  863. * @param string $route
  864. * @return PageInterface|null
  865. */
  866. protected function findSiteBasedRoute($route)
  867. {
  868. /** @var Config $config */
  869. $config = $this->grav['config'];
  870. $site_routes = $config->get('site.routes');
  871. if (!is_array($site_routes)) {
  872. return null;
  873. }
  874. $page = null;
  875. // See if route matches one in the site configuration
  876. $site_route = $site_routes[$route] ?? null;
  877. if ($site_route) {
  878. $page = $this->find($site_route);
  879. } else {
  880. // Use reverse order because of B/C (previously matched multiple and returned the last match).
  881. foreach (array_reverse($site_routes, true) as $pattern => $replace) {
  882. $pattern = '#^' . str_replace('/', '\/', ltrim($pattern, '^')) . '#';
  883. try {
  884. $found = preg_replace($pattern, $replace, $route);
  885. if ($found && $found !== $route) {
  886. $page = $this->find($found);
  887. if ($page) {
  888. return $page;
  889. }
  890. }
  891. } catch (ErrorException $e) {
  892. $this->grav['log']->error('site.routes: ' . $pattern . '-> ' . $e->getMessage());
  893. }
  894. }
  895. }
  896. return $page;
  897. }
  898. /**
  899. * Dispatch URI to a page.
  900. *
  901. * @param string $route The relative URL of the page
  902. * @param bool $all If true, return also non-routable pages, otherwise return null if page isn't routable
  903. * @param bool $redirect If true, allow redirects
  904. * @return PageInterface|null
  905. * @throws Exception
  906. */
  907. public function dispatch($route, $all = false, $redirect = true)
  908. {
  909. $page = $this->find($route, true);
  910. // If we want all pages or are in admin, return what we already have.
  911. if ($all || isset($this->grav['admin'])) {
  912. return $page;
  913. }
  914. if ($page) {
  915. $routable = $page->routable();
  916. if ($redirect) {
  917. if ($page->redirect()) {
  918. // Follow a redirect page.
  919. $this->grav->redirectLangSafe($page->redirect());
  920. }
  921. if (!$routable) {
  922. /** @var Collection $children */
  923. $children = $page->children()->visible()->routable()->published();
  924. $child = $children->first();
  925. if ($child !== null) {
  926. // Redirect to the first visible child as current page isn't routable.
  927. $this->grav->redirectLangSafe($child->route());
  928. }
  929. }
  930. }
  931. if ($routable) {
  932. return $page;
  933. }
  934. }
  935. $route = urldecode((string)$route);
  936. // The page cannot be reached, look into site wide redirects, routes and wildcards.
  937. $redirectedPage = $this->findSiteBasedRoute($route);
  938. if ($redirectedPage) {
  939. $page = $this->dispatch($redirectedPage->route(), false, $redirect);
  940. }
  941. /** @var Config $config */
  942. $config = $this->grav['config'];
  943. /** @var Uri $uri */
  944. $uri = $this->grav['uri'];
  945. /** @var \Grav\Framework\Uri\Uri $source_url */
  946. $source_url = $uri->uri(false);
  947. // Try Regex style redirects
  948. $site_redirects = $config->get('site.redirects');
  949. if (is_array($site_redirects)) {
  950. foreach ((array)$site_redirects as $pattern => $replace) {
  951. $pattern = ltrim($pattern, '^');
  952. $pattern = '#^' . str_replace('/', '\/', $pattern) . '#';
  953. try {
  954. /** @var string $found */
  955. $found = preg_replace($pattern, $replace, $source_url);
  956. if ($found && $found !== $source_url) {
  957. $this->grav->redirectLangSafe($found);
  958. }
  959. } catch (ErrorException $e) {
  960. $this->grav['log']->error('site.redirects: ' . $pattern . '-> ' . $e->getMessage());
  961. }
  962. }
  963. }
  964. return $page;
  965. }
  966. /**
  967. * Get root page.
  968. *
  969. * @return PageInterface
  970. * @throws RuntimeException
  971. */
  972. public function root()
  973. {
  974. /** @var UniformResourceLocator $locator */
  975. $locator = $this->grav['locator'];
  976. $path = $locator->findResource('page://');
  977. $root = is_string($path) ? $this->get(rtrim($path, '/')) : null;
  978. if (null === $root) {
  979. throw new RuntimeException('Internal error');
  980. }
  981. return $root;
  982. }
  983. /**
  984. * Get a blueprint for a page type.
  985. *
  986. * @param string $type
  987. * @return Blueprint
  988. */
  989. public function blueprints($type)
  990. {
  991. if ($this->blueprints === null) {
  992. $this->blueprints = new Blueprints(self::getTypes());
  993. }
  994. try {
  995. $blueprint = $this->blueprints->get($type);
  996. } catch (RuntimeException $e) {
  997. $blueprint = $this->blueprints->get('default');
  998. }
  999. if (empty($blueprint->initialized)) {
  1000. $blueprint->initialized = true;
  1001. $this->grav->fireEvent('onBlueprintCreated', new Event(['blueprint' => $blueprint, 'type' => $type]));
  1002. }
  1003. return $blueprint;
  1004. }
  1005. /**
  1006. * Get all pages
  1007. *
  1008. * @param PageInterface|null $current
  1009. * @return Collection
  1010. */
  1011. public function all(PageInterface $current = null)
  1012. {
  1013. $all = new Collection();
  1014. /** @var PageInterface $current */
  1015. $current = $current ?: $this->root();
  1016. if (!$current->root()) {
  1017. $all[$current->path()] = ['slug' => $current->slug()];
  1018. }
  1019. foreach ($current->children() as $next) {
  1020. $all->append($this->all($next));
  1021. }
  1022. return $all;
  1023. }
  1024. /**
  1025. * Get available parents raw routes.
  1026. *
  1027. * @return array
  1028. */
  1029. public static function parentsRawRoutes()
  1030. {
  1031. $rawRoutes = true;
  1032. return self::getParents($rawRoutes);
  1033. }
  1034. /**
  1035. * Get available parents routes
  1036. *
  1037. * @param bool $rawRoutes get the raw route or the normal route
  1038. * @return array
  1039. */
  1040. private static function getParents($rawRoutes)
  1041. {
  1042. $grav = Grav::instance();
  1043. /** @var Pages $pages */
  1044. $pages = $grav['pages'];
  1045. $parents = $pages->getList(null, 0, $rawRoutes);
  1046. if (isset($grav['admin'])) {
  1047. // Remove current route from parents
  1048. /** @var Admin $admin */
  1049. $admin = $grav['admin'];
  1050. $page = $admin->getPage($admin->route);
  1051. $page_route = $page->route();
  1052. if (isset($parents[$page_route])) {
  1053. unset($parents[$page_route]);
  1054. }
  1055. }
  1056. return $parents;
  1057. }
  1058. /**
  1059. * Get list of route/title of all pages. Title is in HTML.
  1060. *
  1061. * @param PageInterface|null $current
  1062. * @param int $level
  1063. * @param bool $rawRoutes
  1064. * @param bool $showAll
  1065. * @param bool $showFullpath
  1066. * @param bool $showSlug
  1067. * @param bool $showModular
  1068. * @param bool $limitLevels
  1069. * @return array
  1070. */
  1071. public function getList(PageInterface $current = null, $level = 0, $rawRoutes = false, $showAll = true, $showFullpath = false, $showSlug = false, $showModular = false, $limitLevels = false)
  1072. {
  1073. if (!$current) {
  1074. if ($level) {
  1075. throw new RuntimeException('Internal error');
  1076. }
  1077. $current = $this->root();
  1078. }
  1079. $list = [];
  1080. if (!$current->root()) {
  1081. if ($rawRoutes) {
  1082. $route = $current->rawRoute();
  1083. } else {
  1084. $route = $current->route();
  1085. }
  1086. if ($showFullpath) {
  1087. $option = htmlspecialchars($current->route());
  1088. } else {
  1089. $extra = $showSlug ? '(' . $current->slug() . ') ' : '';
  1090. $option = str_repeat('&mdash;-', $level). '&rtrif; ' . $extra . htmlspecialchars($current->title());
  1091. }
  1092. $list[$route] = $option;
  1093. }
  1094. if ($limitLevels === false || ($level+1 < $limitLevels)) {
  1095. foreach ($current->children() as $next) {
  1096. if ($showAll || $next->routable() || ($next->isModule() && $showModular)) {
  1097. $list = array_merge($list, $this->getList($next, $level + 1, $rawRoutes, $showAll, $showFullpath, $showSlug, $showModular, $limitLevels));
  1098. }
  1099. }
  1100. }
  1101. return $list;
  1102. }
  1103. /**
  1104. * Get available page types.
  1105. *
  1106. * @return Types
  1107. */
  1108. public static function getTypes()
  1109. {
  1110. if (null === self::$types) {
  1111. $grav = Grav::instance();
  1112. /** @var UniformResourceLocator $locator */
  1113. $locator = $grav['locator'];
  1114. // Prevent calls made before theme:// has been initialized (happens when upgrading old version of Admin plugin).
  1115. if (!$locator->isStream('theme://')) {
  1116. return new Types();
  1117. }
  1118. $scanBlueprintsAndTemplates = static function (Types $types) use ($grav) {
  1119. // Scan blueprints
  1120. $event = new TypesEvent();
  1121. $event->types = $types;
  1122. $grav->fireEvent('onGetPageBlueprints', $event);
  1123. $types->init();
  1124. // Try new location first.
  1125. $lookup = 'theme://blueprints/pages/';
  1126. if (!is_dir($lookup)) {
  1127. $lookup = 'theme://blueprints/';
  1128. }
  1129. $types->scanBlueprints($lookup);
  1130. // Scan templates
  1131. $event = new TypesEvent();
  1132. $event->types = $types;
  1133. $grav->fireEvent('onGetPageTemplates', $event);
  1134. $types->scanTemplates('theme://templates/');
  1135. };
  1136. if ($grav['config']->get('system.cache.enabled')) {
  1137. /** @var Cache $cache */
  1138. $cache = $grav['cache'];
  1139. // Use cached types if possible.
  1140. $types_cache_id = md5('types');
  1141. $types = $cache->fetch($types_cache_id);
  1142. if (!$types instanceof Types) {
  1143. $types = new Types();
  1144. $scanBlueprintsAndTemplates($types);
  1145. $cache->save($types_cache_id, $types);
  1146. }
  1147. } else {
  1148. $types = new Types();
  1149. $scanBlueprintsAndTemplates($types);
  1150. }
  1151. // Register custom paths to the locator.
  1152. $locator = $grav['locator'];
  1153. foreach ($types as $type => $paths) {
  1154. foreach ($paths as $k => $path) {
  1155. if (strpos($path, 'blueprints://') === 0) {
  1156. unset($paths[$k]);
  1157. }
  1158. }
  1159. if ($paths) {
  1160. $locator->addPath('blueprints', "pages/$type.yaml", $paths);
  1161. }
  1162. }
  1163. self::$types = $types;
  1164. }
  1165. return self::$types;
  1166. }
  1167. /**
  1168. * Get available page types.
  1169. *
  1170. * @return array
  1171. */
  1172. public static function types()
  1173. {
  1174. $types = self::getTypes();
  1175. return $types->pageSelect();
  1176. }
  1177. /**
  1178. * Get available page types.
  1179. *
  1180. * @return array
  1181. */
  1182. public static function modularTypes()
  1183. {
  1184. $types = self::getTypes();
  1185. return $types->modularSelect();
  1186. }
  1187. /**
  1188. * Get template types based on page type (standard or modular)
  1189. *
  1190. * @param string|null $type
  1191. * @return array
  1192. */
  1193. public static function pageTypes($type = null)
  1194. {
  1195. if (null === $type && isset(Grav::instance()['admin'])) {
  1196. /** @var Admin $admin */
  1197. $admin = Grav::instance()['admin'];
  1198. /** @var PageInterface|null $page */
  1199. $page = $admin->page();
  1200. $type = $page && $page->isModule() ? 'modular' : 'standard';
  1201. }
  1202. switch ($type) {
  1203. case 'standard':
  1204. return static::types();
  1205. case 'modular':
  1206. return static::modularTypes();
  1207. }
  1208. return [];
  1209. }
  1210. /**
  1211. * Get access levels of the site pages
  1212. *
  1213. * @return array
  1214. */
  1215. public function accessLevels()
  1216. {
  1217. $accessLevels = [];
  1218. foreach ($this->all() as $page) {
  1219. if ($page instanceof PageInterface && isset($page->header()->access)) {
  1220. if (is_array($page->header()->access)) {
  1221. foreach ($page->header()->access as $index => $accessLevel) {
  1222. if (is_array($accessLevel)) {
  1223. foreach ($accessLevel as $innerIndex => $innerAccessLevel) {
  1224. $accessLevels[] = $innerIndex;
  1225. }
  1226. } else {
  1227. $accessLevels[] = $index;
  1228. }
  1229. }
  1230. } else {
  1231. $accessLevels[] = $page->header()->access;
  1232. }
  1233. }
  1234. }
  1235. return array_unique($accessLevels);
  1236. }
  1237. /**
  1238. * Get available parents routes
  1239. *
  1240. * @return array
  1241. */
  1242. public static function parents()
  1243. {
  1244. $rawRoutes = false;
  1245. return self::getParents($rawRoutes);
  1246. }
  1247. /**
  1248. * Gets the home route
  1249. *
  1250. * @return string
  1251. */
  1252. public static function getHomeRoute()
  1253. {
  1254. if (empty(self::$home_route)) {
  1255. $grav = Grav::instance();
  1256. /** @var Config $config */
  1257. $config = $grav['config'];
  1258. /** @var Language $language */
  1259. $language = $grav['language'];
  1260. $home = $config->get('system.home.alias');
  1261. if ($language->enabled()) {
  1262. $home_aliases = $config->get('system.home.aliases');
  1263. if ($home_aliases) {
  1264. $active = $language->getActive();
  1265. $default = $language->getDefault();
  1266. try {
  1267. if ($active) {
  1268. $home = $home_aliases[$active];
  1269. } else {
  1270. $home = $home_aliases[$default];
  1271. }
  1272. } catch (ErrorException $e) {
  1273. $home = $home_aliases[$default];
  1274. }
  1275. }
  1276. }
  1277. self::$home_route = trim($home, '/');
  1278. }
  1279. return self::$home_route;
  1280. }
  1281. /**
  1282. * Needed for testing where we change the home route via config
  1283. *
  1284. * @return string|null
  1285. */
  1286. public static function resetHomeRoute()
  1287. {
  1288. self::$home_route = null;
  1289. return self::getHomeRoute();
  1290. }
  1291. protected function initFlexPages(): void
  1292. {
  1293. /** @var Debugger $debugger */
  1294. $debugger = $this->grav['debugger'];
  1295. $debugger->addMessage('Pages: Flex Directory');
  1296. /** @var Flex $flex */
  1297. $flex = $this->grav['flex'];
  1298. $directory = $flex->getDirectory('pages');
  1299. /** @var EventDispatcher $dispatcher */
  1300. $dispatcher = $this->grav['events'];
  1301. // Stop /admin/pages from working, display error instead.
  1302. $dispatcher->addListener(
  1303. 'onAdminPage',
  1304. static function (Event $event) use ($directory) {
  1305. $grav = Grav::instance();
  1306. $admin = $grav['admin'];
  1307. [$base,$location,] = $admin->getRouteDetails();
  1308. if ($location !== 'pages' || isset($grav['flex_objects'])) {
  1309. return;
  1310. }
  1311. /** @var PageInterface $page */
  1312. $page = $event['page'];
  1313. $page->init(new SplFileInfo('plugin://admin/pages/admin/error.md'));
  1314. $page->routable(true);
  1315. $header = $page->header();
  1316. $header->title = 'Please install missing plugin';
  1317. $page->content("## Please install and enable **[Flex Objects]({$base}/plugins/flex-objects)** plugin. It is required to edit **Flex Pages**.");
  1318. /** @var Header $header */
  1319. $header = $page->header();
  1320. $menu = $directory->getConfig('admin.menu.list');
  1321. $header->access = $menu['authorize'] ?? ['admin.super'];
  1322. },
  1323. 100000
  1324. );
  1325. $this->directory = $directory;
  1326. }
  1327. /**
  1328. * Builds pages.
  1329. *
  1330. * @internal
  1331. */
  1332. protected function buildPages(): void
  1333. {
  1334. /** @var Debugger $debugger */
  1335. $debugger = $this->grav['debugger'];
  1336. $debugger->startTimer('build-pages', 'Init frontend routes');
  1337. if ($this->directory) {
  1338. $this->buildFlexPages($this->directory);
  1339. } else {
  1340. $this->buildRegularPages();
  1341. }
  1342. $debugger->stopTimer('build-pages');
  1343. }
  1344. protected function buildFlexPages(FlexDirectory $directory): void
  1345. {
  1346. /** @var Config $config */
  1347. $config = $this->grav['config'];
  1348. // TODO: right now we are just emulating normal pages, it is inefficient and bad... but works!
  1349. /** @var PageCollection|PageIndex $collection */
  1350. $collection = $directory->getIndex(null, 'storage_key');
  1351. $cache = $directory->getCache('index');
  1352. /** @var Language $language */
  1353. $language = $this->grav['language'];
  1354. $this->pages_cache_id = 'pages-' . md5($collection->getCacheChecksum() . $language->getActive() . $config->checksum());
  1355. $cached = $cache->get($this->pages_cache_id);
  1356. if ($cached && $this->getVersion() === $cached[0]) {
  1357. [, $this->index, $this->routes, $this->children, $taxonomy_map, $this->sort] = $cached;
  1358. /** @var Taxonomy $taxonomy */
  1359. $taxonomy = $this->grav['taxonomy'];
  1360. $taxonomy->taxonomy($taxonomy_map);
  1361. return;
  1362. }
  1363. /** @var Debugger $debugger */
  1364. $debugger = $this->grav['debugger'];
  1365. $debugger->addMessage('Page cache missed, rebuilding Flex Pages..');
  1366. $root = $collection->getRoot();
  1367. $root_path = $root->path();
  1368. $this->routes = [];
  1369. $this->instances = [$root_path => $root];
  1370. $this->index = [$root_path => $root];
  1371. $this->children = [];
  1372. $this->sort = [];
  1373. if ($this->fire_events) {
  1374. $this->grav->fireEvent('onBuildPagesInitialized');
  1375. }
  1376. /** @var PageInterface $page */
  1377. foreach ($collection as $page) {
  1378. $path = $page->path();
  1379. if (null === $path) {
  1380. throw new RuntimeException('Internal error');
  1381. }
  1382. if ($page instanceof FlexTranslateInterface) {
  1383. $page = $page->hasTranslation() ? $page->getTranslation() : null;
  1384. }
  1385. if (!$page instanceof FlexPageObject || $path === $root_path) {
  1386. continue;
  1387. }
  1388. if ($this->fire_events) {
  1389. if (method_exists($page, 'initialize')) {
  1390. $page->initialize();
  1391. } else {
  1392. // TODO: Deprecated, only used in 1.7 betas.
  1393. $this->grav->fireEvent('onPageProcessed', new Event(['page' => $page]));
  1394. }
  1395. }
  1396. $parent = dirname($path);
  1397. $route = $page->rawRoute();
  1398. // Skip duplicated empty folders (git revert does not remove those).
  1399. // TODO: still not perfect, will only work if the page has been translated.
  1400. if (isset($this->routes[$route])) {
  1401. $oldPath = $this->routes[$route];
  1402. if ($page->isPage()) {
  1403. unset($this->index[$oldPath], $this->children[dirname($oldPath)][$oldPath]);
  1404. } else {
  1405. continue;
  1406. }
  1407. }
  1408. $this->routes[$route] = $path;
  1409. $this->instances[$path] = $page;
  1410. $this->index[$path] = $page->getFlexKey();
  1411. // FIXME: ... better...
  1412. $this->children[$parent][$path] = ['slug' => $page->slug()];
  1413. if (!isset($this->children[$path])) {
  1414. $this->children[$path] = [];
  1415. }
  1416. }
  1417. foreach ($this->children as $path => $list) {
  1418. $page = $this->instances[$path] ?? null;
  1419. if (null === $page) {
  1420. continue;
  1421. }
  1422. // Call onFolderProcessed event.
  1423. if ($this->fire_events) {
  1424. $this->grav->fireEvent('onFolderProcessed', new Event(['page' => $page]));
  1425. }
  1426. // Sort the children.
  1427. $this->children[$path] = $this->sort($page);
  1428. }
  1429. $this->routes = [];
  1430. $this->buildRoutes();
  1431. // cache if needed
  1432. if (null !== $cache) {
  1433. /** @var Taxonomy $taxonomy */
  1434. $taxonomy = $this->grav['taxonomy'];
  1435. $taxonomy_map = $taxonomy->taxonomy();
  1436. // save pages, routes, taxonomy, and sort to cache
  1437. $cache->set($this->pages_cache_id, [$this->getVersion(), $this->index, $this->routes, $this->children, $taxonomy_map, $this->sort]);
  1438. }
  1439. }
  1440. /**
  1441. * @return Page
  1442. */
  1443. protected function buildRootPage()
  1444. {
  1445. $grav = Grav::instance();
  1446. /** @var UniformResourceLocator $locator */
  1447. $locator = $grav['locator'];
  1448. $path = $locator->findResource('page://');
  1449. if (!is_string($path)) {
  1450. throw new RuntimeException('Internal Error');
  1451. }
  1452. /** @var Config $config */
  1453. $config = $grav['config'];
  1454. $page = new Page();
  1455. $page->path($path);
  1456. $page->orderDir($config->get('system.pages.order.dir'));
  1457. $page->orderBy($config->get('system.pages.order.by'));
  1458. $page->modified(0);
  1459. $page->routable(false);
  1460. $page->template('default');
  1461. $page->extension('.md');
  1462. return $page;
  1463. }
  1464. protected function buildRegularPages(): void
  1465. {
  1466. /** @var Config $config */
  1467. $config = $this->grav['config'];
  1468. /** @var UniformResourceLocator $locator */
  1469. $locator = $this->grav['locator'];
  1470. /** @var Language $language */
  1471. $language = $this->grav['language'];
  1472. $pages_dirs = $this->getPagesPaths();
  1473. // Set active language
  1474. $this->active_lang = $language->getActive();
  1475. if ($config->get('system.cache.enabled')) {
  1476. /** @var Language $language */
  1477. $language = $this->grav['language'];
  1478. // how should we check for last modified? Default is by file
  1479. switch ($this->check_method) {
  1480. case 'none':
  1481. case 'off':
  1482. $hash = 0;
  1483. break;
  1484. case 'folder':
  1485. $hash = Folder::lastModifiedFolder($pages_dirs);
  1486. break;
  1487. case 'hash':
  1488. $hash = Folder::hashAllFiles($pages_dirs);
  1489. break;
  1490. default:
  1491. $hash = Folder::lastModifiedFile($pages_dirs);
  1492. }
  1493. $this->simple_pages_hash = json_encode($pages_dirs) . $hash . $config->checksum();
  1494. $this->pages_cache_id = md5($this->simple_pages_hash . $language->getActive());
  1495. /** @var Cache $cache */
  1496. $cache = $this->grav['cache'];
  1497. $cached = $cache->fetch($this->pages_cache_id);
  1498. if ($cached && $this->getVersion() === $cached[0]) {
  1499. [, $this->index, $this->routes, $this->children, $taxonomy_map, $this->sort] = $cached;
  1500. /** @var Taxonomy $taxonomy */
  1501. $taxonomy = $this->grav['taxonomy'];
  1502. $taxonomy->taxonomy($taxonomy_map);
  1503. return;
  1504. }
  1505. $this->grav['debugger']->addMessage('Page cache missed, rebuilding pages..');
  1506. } else {
  1507. $this->grav['debugger']->addMessage('Page cache disabled, rebuilding pages..');
  1508. }
  1509. $this->resetPages($pages_dirs);
  1510. }
  1511. protected function getPagesPaths(): array
  1512. {
  1513. $grav = Grav::instance();
  1514. $locator = $grav['locator'];
  1515. $paths = [];
  1516. $dirs = (array) $grav['config']->get('system.pages.dirs', ['page://']);
  1517. foreach ($dirs as $dir) {
  1518. $path = $locator->findResource($dir);
  1519. if (file_exists($path) && !in_array($path, $paths, true)) {
  1520. $paths[] = $path;
  1521. }
  1522. }
  1523. return $paths;
  1524. }
  1525. /**
  1526. * Accessible method to manually reset the pages cache
  1527. *
  1528. * @param array $pages_dirs
  1529. */
  1530. public function resetPages(array $pages_dirs): void
  1531. {
  1532. $this->sort = [];
  1533. foreach ($pages_dirs as $dir) {
  1534. $this->recurse($dir);
  1535. }
  1536. $this->buildRoutes();
  1537. // cache if needed
  1538. if ($this->grav['config']->get('system.cache.enabled')) {
  1539. /** @var Cache $cache */
  1540. $cache = $this->grav['cache'];
  1541. /** @var Taxonomy $taxonomy */
  1542. $taxonomy = $this->grav['taxonomy'];
  1543. // save pages, routes, taxonomy, and sort to cache
  1544. $cache->save($this->pages_cache_id, [$this->getVersion(), $this->index, $this->routes, $this->children, $taxonomy->taxonomy(), $this->sort]);
  1545. }
  1546. }
  1547. /**
  1548. * Recursive function to load & build page relationships.
  1549. *
  1550. * @param string $directory
  1551. * @param PageInterface|null $parent
  1552. * @return PageInterface
  1553. * @throws RuntimeException
  1554. * @internal
  1555. */
  1556. protected function recurse(string $directory, PageInterface $parent = null)
  1557. {
  1558. $directory = rtrim($directory, DS);
  1559. $page = new Page;
  1560. /** @var Config $config */
  1561. $config = $this->grav['config'];
  1562. /** @var Language $language */
  1563. $language = $this->grav['language'];
  1564. // Stuff to do at root page
  1565. // Fire event for memory and time consuming plugins...
  1566. if ($parent === null && $this->fire_events) {
  1567. $this->grav->fireEvent('onBuildPagesInitialized');
  1568. }
  1569. $page->path($directory);
  1570. if ($parent) {
  1571. $page->parent($parent);
  1572. }
  1573. $page->orderDir($config->get('system.pages.order.dir'));
  1574. $page->orderBy($config->get('system.pages.order.by'));
  1575. // Add into instances
  1576. if (!isset($this->index[$page->path()])) {
  1577. $this->index[$page->path()] = $page;
  1578. $this->instances[$page->path()] = $page;
  1579. if ($parent && $page->path()) {
  1580. $this->children[$parent->path()][$page->path()] = ['slug' => $page->slug()];
  1581. }
  1582. } elseif ($parent !== null) {
  1583. throw new RuntimeException('Fatal error when creating page instances.');
  1584. }
  1585. // Build regular expression for all the allowed page extensions.
  1586. $page_extensions = $language->getFallbackPageExtensions();
  1587. $regex = '/^[^\.]*(' . implode('|', array_map(
  1588. static function ($str) {
  1589. return preg_quote($str, '/');
  1590. },
  1591. $page_extensions
  1592. )) . ')$/';
  1593. $folders = [];
  1594. $page_found = null;
  1595. $page_extension = '.md';
  1596. $last_modified = 0;
  1597. $iterator = new FilesystemIterator($directory);
  1598. foreach ($iterator as $file) {
  1599. $filename = $file->getFilename();
  1600. // Ignore all hidden files if set.
  1601. if ($this->ignore_hidden && $filename && strpos($filename, '.') === 0) {
  1602. continue;
  1603. }
  1604. // Handle folders later.
  1605. if ($file->isDir()) {
  1606. // But ignore all folders in ignore list.
  1607. if (!in_array($filename, $this->ignore_folders, true)) {
  1608. $folders[] = $file;
  1609. }
  1610. continue;
  1611. }
  1612. // Ignore all files in ignore list.
  1613. if (in_array($filename, $this->ignore_files, true)) {
  1614. continue;
  1615. }
  1616. // Update last modified date to match the last updated file in the folder.
  1617. $modified = $file->getMTime();
  1618. if ($modified > $last_modified) {
  1619. $last_modified = $modified;
  1620. }
  1621. // Page is the one that matches to $page_extensions list with the lowest index number.
  1622. if (preg_match($regex, $filename, $matches, PREG_OFFSET_CAPTURE)) {
  1623. $ext = $matches[1][0];
  1624. if ($page_found === null || array_search($ext, $page_extensions, true) < array_search($page_extension, $page_extensions, true)) {
  1625. $page_found = $file;
  1626. $page_extension = $ext;
  1627. }
  1628. }
  1629. }
  1630. $content_exists = false;
  1631. if ($parent && $page_found) {
  1632. $page->init($page_found, $page_extension);
  1633. $content_exists = true;
  1634. if ($this->fire_events) {
  1635. $this->grav->fireEvent('onPageProcessed', new Event(['page' => $page]));
  1636. }
  1637. }
  1638. // Now handle all the folders under the page.
  1639. /** @var FilesystemIterator $file */
  1640. foreach ($folders as $file) {
  1641. $filename = $file->getFilename();
  1642. // if folder contains separator, continue
  1643. if (Utils::contains($file->getFilename(), $config->get('system.param_sep', ':'))) {
  1644. continue;
  1645. }
  1646. if (!$page->path()) {
  1647. $page->path($file->getPath());
  1648. }
  1649. $path = $directory . DS . $filename;
  1650. $child = $this->recurse($path, $page);
  1651. if (preg_match('/^(\d+\.)_/', $filename)) {
  1652. $child->routable(false);
  1653. $child->modularTwig(true);
  1654. }
  1655. $this->children[$page->path()][$child->path()] = ['slug' => $child->slug()];
  1656. if ($this->fire_events) {
  1657. $this->grav->fireEvent('onFolderProcessed', new Event(['page' => $page]));
  1658. }
  1659. }
  1660. if (!$content_exists) {
  1661. // Set routable to false if no page found
  1662. $page->routable(false);
  1663. // Hide empty folders if option set
  1664. if ($config->get('system.pages.hide_empty_folders')) {
  1665. $page->visible(false);
  1666. }
  1667. }
  1668. // Override the modified time if modular
  1669. if ($page->template() === 'modular') {
  1670. foreach ($page->collection() as $child) {
  1671. $modified = $child->modified();
  1672. if ($modified > $last_modified) {
  1673. $last_modified = $modified;
  1674. }
  1675. }
  1676. }
  1677. // Override the modified and ID so that it takes the latest change into account
  1678. $page->modified($last_modified);
  1679. $page->id($last_modified . md5($page->filePath() ?? ''));
  1680. // Sort based on Defaults or Page Overridden sort order
  1681. $this->children[$page->path()] = $this->sort($page);
  1682. return $page;
  1683. }
  1684. /**
  1685. * @internal
  1686. */
  1687. protected function buildRoutes(): void
  1688. {
  1689. /** @var Taxonomy $taxonomy */
  1690. $taxonomy = $this->grav['taxonomy'];
  1691. // Get the home route
  1692. $home = self::resetHomeRoute();
  1693. // Build routes and taxonomy map.
  1694. /** @var PageInterface|string $page */
  1695. foreach ($this->index as $path => $page) {
  1696. if (is_string($page)) {
  1697. $page = $this->get($path);
  1698. }
  1699. if (!$page || $page->root()) {
  1700. continue;
  1701. }
  1702. // process taxonomy
  1703. $taxonomy->addTaxonomy($page);
  1704. $page_path = $page->path();
  1705. if (null === $page_path) {
  1706. throw new RuntimeException('Internal Error');
  1707. }
  1708. $route = $page->route();
  1709. $raw_route = $page->rawRoute();
  1710. // add regular route
  1711. if ($route) {
  1712. $this->routes[$route] = $page_path;
  1713. }
  1714. // add raw route
  1715. if ($raw_route && $raw_route !== $route) {
  1716. $this->routes[$raw_route] = $page_path;
  1717. }
  1718. // add canonical route
  1719. $route_canonical = $page->routeCanonical();
  1720. if ($route_canonical && $route !== $route_canonical) {
  1721. $this->routes[$route_canonical] = $page_path;
  1722. }
  1723. // add aliases to routes list if they are provided
  1724. $route_aliases = $page->routeAliases();
  1725. if ($route_aliases) {
  1726. foreach ($route_aliases as $alias) {
  1727. $this->routes[$alias] = $page_path;
  1728. }
  1729. }
  1730. }
  1731. // Alias and set default route to home page.
  1732. $homeRoute = "/{$home}";
  1733. if ($home && isset($this->routes[$homeRoute])) {
  1734. $home = $this->get($this->routes[$homeRoute]);
  1735. if ($home) {
  1736. $this->routes['/'] = $this->routes[$homeRoute];
  1737. $home->route('/');
  1738. }
  1739. }
  1740. }
  1741. /**
  1742. * @param string $path
  1743. * @param array $pages
  1744. * @param string $order_by
  1745. * @param array|null $manual
  1746. * @param int|null $sort_flags
  1747. * @throws RuntimeException
  1748. * @internal
  1749. */
  1750. protected function buildSort($path, array $pages, $order_by = 'default', $manual = null, $sort_flags = null): void
  1751. {
  1752. $list = [];
  1753. $header_query = null;
  1754. $header_default = null;
  1755. // do this header query work only once
  1756. if (strpos($order_by, 'header.') === 0) {
  1757. $query = explode('|', str_replace('header.', '', $order_by), 2);
  1758. $header_query = array_shift($query) ?? '';
  1759. $header_default = array_shift($query);
  1760. }
  1761. foreach ($pages as $key => $info) {
  1762. $child = $this->get($key);
  1763. if (!$child) {
  1764. throw new RuntimeException("Page does not exist: {$key}");
  1765. }
  1766. switch ($order_by) {
  1767. case 'title':
  1768. $list[$key] = $child->title();
  1769. break;
  1770. case 'date':
  1771. $list[$key] = $child->date();
  1772. $sort_flags = SORT_REGULAR;
  1773. break;
  1774. case 'modified':
  1775. $list[$key] = $child->modified();
  1776. $sort_flags = SORT_REGULAR;
  1777. break;
  1778. case 'publish_date':
  1779. $list[$key] = $child->publishDate();
  1780. $sort_flags = SORT_REGULAR;
  1781. break;
  1782. case 'unpublish_date':
  1783. $list[$key] = $child->unpublishDate();
  1784. $sort_flags = SORT_REGULAR;
  1785. break;
  1786. case 'slug':
  1787. $list[$key] = $child->slug();
  1788. break;
  1789. case 'basename':
  1790. $list[$key] = Utils::basename($key);
  1791. break;
  1792. case 'folder':
  1793. $list[$key] = $child->folder();
  1794. break;
  1795. case 'manual':
  1796. case 'default':
  1797. default:
  1798. if (is_string($header_query)) {
  1799. $child_header = $child->header();
  1800. if (!$child_header instanceof Header) {
  1801. $child_header = new Header((array)$child_header);
  1802. }
  1803. $header_value = $child_header->get($header_query);
  1804. if (is_array($header_value)) {
  1805. $list[$key] = implode(',', $header_value);
  1806. } elseif ($header_value) {
  1807. $list[$key] = $header_value;
  1808. } else {
  1809. $list[$key] = $header_default ?: $key;
  1810. }
  1811. $sort_flags = $sort_flags ?: SORT_REGULAR;
  1812. break;
  1813. }
  1814. $list[$key] = $key;
  1815. $sort_flags = $sort_flags ?: SORT_REGULAR;
  1816. }
  1817. }
  1818. if (!$sort_flags) {
  1819. $sort_flags = SORT_NATURAL | SORT_FLAG_CASE;
  1820. }
  1821. // handle special case when order_by is random
  1822. if ($order_by === 'random') {
  1823. $list = $this->arrayShuffle($list);
  1824. } else {
  1825. // else just sort the list according to specified key
  1826. if (extension_loaded('intl') && $this->grav['config']->get('system.intl_enabled')) {
  1827. $locale = setlocale(LC_COLLATE, '0'); //`setlocale` with a '0' param returns the current locale set
  1828. $col = Collator::create($locale);
  1829. if ($col) {
  1830. $col->setAttribute(Collator::NUMERIC_COLLATION, Collator::ON);
  1831. if (($sort_flags & SORT_NATURAL) === SORT_NATURAL) {
  1832. $list = preg_replace_callback('~([0-9]+)\.~', static function ($number) {
  1833. return sprintf('%032d.', $number[0]);
  1834. }, $list);
  1835. if (!is_array($list)) {
  1836. throw new RuntimeException('Internal Error');
  1837. }
  1838. $list_vals = array_values($list);
  1839. if (is_numeric(array_shift($list_vals))) {
  1840. $sort_flags = Collator::SORT_REGULAR;
  1841. } else {
  1842. $sort_flags = Collator::SORT_STRING;
  1843. }
  1844. }
  1845. $col->asort($list, $sort_flags);
  1846. } else {
  1847. asort($list, $sort_flags);
  1848. }
  1849. } else {
  1850. asort($list, $sort_flags);
  1851. }
  1852. }
  1853. // Move manually ordered items into the beginning of the list. Order of the unlisted items does not change.
  1854. if (is_array($manual) && !empty($manual)) {
  1855. $new_list = [];
  1856. $i = count($manual);
  1857. foreach ($list as $key => $dummy) {
  1858. $info = $pages[$key];
  1859. $order = array_search($info['slug'], $manual, true);
  1860. if ($order === false) {
  1861. $order = $i++;
  1862. }
  1863. $new_list[$key] = (int)$order;
  1864. }
  1865. $list = $new_list;
  1866. // Apply manual ordering to the list.
  1867. asort($list, SORT_NUMERIC);
  1868. }
  1869. foreach ($list as $key => $sort) {
  1870. $info = $pages[$key];
  1871. $this->sort[$path][$order_by][$key] = $info;
  1872. }
  1873. }
  1874. /**
  1875. * Shuffles an associative array
  1876. *
  1877. * @param array $list
  1878. * @return array
  1879. */
  1880. protected function arrayShuffle(array $list): array
  1881. {
  1882. $keys = array_keys($list);
  1883. shuffle($keys);
  1884. $new = [];
  1885. foreach ($keys as $key) {
  1886. $new[$key] = $list[$key];
  1887. }
  1888. return $new;
  1889. }
  1890. /**
  1891. * @return string
  1892. */
  1893. protected function getVersion(): string
  1894. {
  1895. return $this->directory ? 'flex' : 'regular';
  1896. }
  1897. /**
  1898. * Get the Pages cache ID
  1899. *
  1900. * this is particularly useful to know if pages have changed and you want
  1901. * to sync another cache with pages cache - works best in `onPagesInitialized()`
  1902. *
  1903. * @return null|string
  1904. */
  1905. public function getPagesCacheId(): ?string
  1906. {
  1907. return $this->pages_cache_id;
  1908. }
  1909. /**
  1910. * Get the simple pages hash that is not md5 encoded, and isn't specific to language
  1911. *
  1912. * @return null|string
  1913. */
  1914. public function getSimplePagesHash(): ?string
  1915. {
  1916. return $this->simple_pages_hash;
  1917. }
  1918. }