Pages.php 69 KB

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