Pages.php 68 KB

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