Pico.php 70 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995
  1. <?php
  2. /**
  3. * Pico
  4. *
  5. * Pico is a stupidly simple, blazing fast, flat file CMS.
  6. *
  7. * - Stupidly Simple: Pico makes creating and maintaining a
  8. * website as simple as editing text files.
  9. * - Blazing Fast: Pico is seriously lightweight and doesn't
  10. * use a database, making it super fast.
  11. * - No Database: Pico is a "flat file" CMS, meaning no
  12. * database woes, no MySQL queries, nothing.
  13. * - Markdown Formatting: Edit your website in your favourite
  14. * text editor using simple Markdown formatting.
  15. * - Twig Templates: Pico uses the Twig templating engine,
  16. * for powerful and flexible themes.
  17. * - Open Source: Pico is completely free and open source,
  18. * released under the MIT license.
  19. *
  20. * See <http://picocms.org/> for more info.
  21. *
  22. * @author Gilbert Pellegrom
  23. * @author Daniel Rudolf
  24. * @link http://picocms.org
  25. * @license http://opensource.org/licenses/MIT The MIT License
  26. * @version 2.0
  27. */
  28. class Pico
  29. {
  30. /**
  31. * Pico version
  32. *
  33. * @var string
  34. */
  35. const VERSION = '2.0.0-dev';
  36. /**
  37. * Pico version ID
  38. *
  39. * @var int
  40. */
  41. const VERSION_ID = 20000;
  42. /**
  43. * Sort files in alphabetical ascending order
  44. *
  45. * @see Pico::getFiles()
  46. * @var int
  47. */
  48. const SORT_ASC = 0;
  49. /**
  50. * Sort files in alphabetical descending order
  51. *
  52. * @see Pico::getFiles()
  53. * @var int
  54. */
  55. const SORT_DESC = 1;
  56. /**
  57. * Don't sort files
  58. *
  59. * @see Pico::getFiles()
  60. * @var int
  61. */
  62. const SORT_NONE = 2;
  63. /**
  64. * Root directory of this Pico instance
  65. *
  66. * @see Pico::getRootDir()
  67. * @var string
  68. */
  69. protected $rootDir;
  70. /**
  71. * Config directory of this Pico instance
  72. *
  73. * @see Pico::getConfigDir()
  74. * @var string
  75. */
  76. protected $configDir;
  77. /**
  78. * Plugins directory of this Pico instance
  79. *
  80. * @see Pico::getPluginsDir()
  81. * @var string
  82. */
  83. protected $pluginsDir;
  84. /**
  85. * Themes directory of this Pico instance
  86. *
  87. * @see Pico::getThemesDir()
  88. * @var string
  89. */
  90. protected $themesDir;
  91. /**
  92. * Boolean indicating whether Pico started processing yet
  93. *
  94. * @var boolean
  95. */
  96. protected $locked = false;
  97. /**
  98. * List of loaded plugins
  99. *
  100. * @see Pico::getPlugins()
  101. * @var object[]|null
  102. */
  103. protected $plugins;
  104. /**
  105. * Current configuration of this Pico instance
  106. *
  107. * @see Pico::getConfig()
  108. * @var array|null
  109. */
  110. protected $config;
  111. /**
  112. * Part of the URL describing the requested contents
  113. *
  114. * @see Pico::getRequestUrl()
  115. * @var string|null
  116. */
  117. protected $requestUrl;
  118. /**
  119. * Absolute path to the content file being served
  120. *
  121. * @see Pico::getRequestFile()
  122. * @var string|null
  123. */
  124. protected $requestFile;
  125. /**
  126. * Raw, not yet parsed contents to serve
  127. *
  128. * @see Pico::getRawContent()
  129. * @var string|null
  130. */
  131. protected $rawContent;
  132. /**
  133. * Boolean indicating whether Pico is serving a 404 page
  134. *
  135. * @see Pico::is404Content()
  136. * @var boolean
  137. */
  138. protected $is404Content = false;
  139. /**
  140. * Symfony YAML instance used for meta header parsing
  141. *
  142. * @see Pico::getYamlParser()
  143. * @var \Symfony\Component\Yaml\Parser|null
  144. */
  145. protected $yamlParser;
  146. /**
  147. * List of known meta headers
  148. *
  149. * @see Pico::getMetaHeaders()
  150. * @var string[]|null
  151. */
  152. protected $metaHeaders;
  153. /**
  154. * Meta data of the page to serve
  155. *
  156. * @see Pico::getFileMeta()
  157. * @var array|null
  158. */
  159. protected $meta;
  160. /**
  161. * Parsedown Extra instance used for markdown parsing
  162. *
  163. * @see Pico::getParsedown()
  164. * @var ParsedownExtra|null
  165. */
  166. protected $parsedown;
  167. /**
  168. * Parsed content being served
  169. *
  170. * @see Pico::getFileContent()
  171. * @var string|null
  172. */
  173. protected $content;
  174. /**
  175. * List of known pages
  176. *
  177. * @see Pico::getPages()
  178. * @var array[]|null
  179. */
  180. protected $pages;
  181. /**
  182. * Data of the page being served
  183. *
  184. * @see Pico::getCurrentPage()
  185. * @var array|null
  186. */
  187. protected $currentPage;
  188. /**
  189. * Data of the previous page relative to the page being served
  190. *
  191. * @see Pico::getPreviousPage()
  192. * @var array|null
  193. */
  194. protected $previousPage;
  195. /**
  196. * Data of the next page relative to the page being served
  197. *
  198. * @see Pico::getNextPage()
  199. * @var array|null
  200. */
  201. protected $nextPage;
  202. /**
  203. * Twig instance used for template parsing
  204. *
  205. * @see Pico::getTwig()
  206. * @var Twig_Environment|null
  207. */
  208. protected $twig;
  209. /**
  210. * Variables passed to the twig template
  211. *
  212. * @see Pico::getTwigVariables()
  213. * @var array|null
  214. */
  215. protected $twigVariables;
  216. /**
  217. * Name of the Twig template to render
  218. *
  219. * @see Pico::getTwigTemplate()
  220. * @var string|null
  221. */
  222. protected $twigTemplate;
  223. /**
  224. * Constructs a new Pico instance
  225. *
  226. * To carry out all the processing in Pico, call {@link Pico::run()}.
  227. *
  228. * @param string $rootDir root directory of this Pico instance
  229. * @param string $configDir config directory of this Pico instance
  230. * @param string $pluginsDir plugins directory of this Pico instance
  231. * @param string $themesDir themes directory of this Pico instance
  232. */
  233. public function __construct($rootDir, $configDir, $pluginsDir, $themesDir)
  234. {
  235. $this->rootDir = rtrim($rootDir, '/\\') . '/';
  236. $this->configDir = $this->getAbsolutePath($configDir);
  237. $this->pluginsDir = $this->getAbsolutePath($pluginsDir);
  238. $this->themesDir = $this->getAbsolutePath($themesDir);
  239. }
  240. /**
  241. * Returns the root directory of this Pico instance
  242. *
  243. * @return string root directory path
  244. */
  245. public function getRootDir()
  246. {
  247. return $this->rootDir;
  248. }
  249. /**
  250. * Returns the config directory of this Pico instance
  251. *
  252. * @return string config directory path
  253. */
  254. public function getConfigDir()
  255. {
  256. return $this->configDir;
  257. }
  258. /**
  259. * Returns the plugins directory of this Pico instance
  260. *
  261. * @return string plugins directory path
  262. */
  263. public function getPluginsDir()
  264. {
  265. return $this->pluginsDir;
  266. }
  267. /**
  268. * Returns the themes directory of this Pico instance
  269. *
  270. * @return string themes directory path
  271. */
  272. public function getThemesDir()
  273. {
  274. return $this->themesDir;
  275. }
  276. /**
  277. * Runs this Pico instance
  278. *
  279. * Loads plugins, evaluates the config file, does URL routing, parses
  280. * meta headers, processes Markdown, does Twig processing and returns
  281. * the rendered contents.
  282. *
  283. * @return string rendered Pico contents
  284. * @throws Exception thrown when a not recoverable error occurs
  285. */
  286. public function run()
  287. {
  288. // check lock
  289. if ($this->locked) {
  290. throw new LogicException('You cannot run the same Pico instance multiple times');
  291. }
  292. // lock Pico
  293. $this->locked = true;
  294. // load plugins
  295. $this->loadPlugins();
  296. $this->sortPlugins();
  297. $this->triggerEvent('onPluginsLoaded', array(&$this->plugins));
  298. // load config
  299. $this->loadConfig();
  300. $this->triggerEvent('onConfigLoaded', array(&$this->config));
  301. // check content dir
  302. if (!is_dir($this->getConfig('content_dir'))) {
  303. throw new RuntimeException('Invalid content directory "' . $this->getConfig('content_dir') . '"');
  304. }
  305. // evaluate request url
  306. $this->evaluateRequestUrl();
  307. $this->triggerEvent('onRequestUrl', array(&$this->requestUrl));
  308. // discover requested file
  309. $this->requestFile = $this->resolveFilePath($this->requestUrl);
  310. $this->triggerEvent('onRequestFile', array(&$this->requestFile));
  311. // load raw file content
  312. $this->triggerEvent('onContentLoading', array(&$this->requestFile));
  313. $hiddenFileRegex = '/(?:^|\/)(?:_|404' . preg_quote($this->getConfig('content_ext'), '/') . '$)/';
  314. if (file_exists($this->requestFile) && !preg_match($hiddenFileRegex, $this->requestFile)) {
  315. $this->rawContent = $this->loadFileContent($this->requestFile);
  316. } else {
  317. $this->triggerEvent('on404ContentLoading', array(&$this->requestFile));
  318. header($_SERVER['SERVER_PROTOCOL'] . ' 404 Not Found');
  319. $this->rawContent = $this->load404Content($this->requestFile);
  320. $this->is404Content = true;
  321. $this->triggerEvent('on404ContentLoaded', array(&$this->rawContent));
  322. }
  323. $this->triggerEvent('onContentLoaded', array(&$this->rawContent));
  324. // register YAML parser
  325. $this->triggerEvent('onYamlParserRegistration');
  326. // parse file meta
  327. $this->metaHeaders = $this->getMetaHeaders();
  328. $this->triggerEvent('onMetaHeaders', array(&$this->metaHeaders));
  329. $this->triggerEvent('onMetaParsing', array(&$this->rawContent, &$this->metaHeaders));
  330. $this->meta = $this->parseFileMeta($this->rawContent, $this->metaHeaders);
  331. $this->triggerEvent('onMetaParsed', array(&$this->meta));
  332. // register parsedown
  333. $this->triggerEvent('onParsedownRegistration');
  334. // parse file content
  335. $this->triggerEvent('onContentParsing', array(&$this->rawContent));
  336. $this->content = $this->prepareFileContent($this->rawContent, $this->meta);
  337. $this->triggerEvent('onContentPrepared', array(&$this->content));
  338. $this->content = $this->parseFileContent($this->content);
  339. $this->triggerEvent('onContentParsed', array(&$this->content));
  340. // read pages
  341. $this->triggerEvent('onPagesLoading');
  342. $this->readPages();
  343. $this->sortPages();
  344. $this->discoverPageSiblings();
  345. $this->discoverCurrentPage();
  346. $this->triggerEvent('onPagesLoaded', array(
  347. &$this->pages,
  348. &$this->currentPage,
  349. &$this->previousPage,
  350. &$this->nextPage
  351. ));
  352. // register twig
  353. $this->triggerEvent('onTwigRegistration');
  354. $this->registerTwig();
  355. // render template
  356. $this->twigVariables = $this->getTwigVariables();
  357. $this->twigTemplate = $this->getTwigTemplate();
  358. $this->triggerEvent('onPageRendering', array(&$this->twig, &$this->twigVariables, &$this->twigTemplate));
  359. $output = $this->twig->render($this->twigTemplate, $this->twigVariables);
  360. $this->triggerEvent('onPageRendered', array(&$output));
  361. return $output;
  362. }
  363. /**
  364. * Loads plugins from Pico::$pluginsDir in alphabetical order
  365. *
  366. * Pico tries to load plugins from `<plugin name>/<plugin name>.php` and
  367. * `<plugin name>.php` only. Plugin names are treated case insensitive.
  368. * Pico will throw a RuntimeException if it can't load a plugin.
  369. *
  370. * Plugin files MAY be prefixed by a number (e.g. 00-PicoDeprecated.php)
  371. * to indicate their processing order. Plugins without a prefix will be
  372. * loaded last. If you want to use a prefix, you MUST NOT use the reserved
  373. * prefixes `00` to `09`. Prefixes are completely optional, however, you
  374. * SHOULD take the following prefix classification into consideration:
  375. * - 10 to 19: Reserved
  376. * - 20 to 39: Low level code helper plugins
  377. * - 40 to 59: Plugins manipulating routing or the pages array
  378. * - 60 to 79: Plugins hooking into template or markdown parsing
  379. * - 80 to 99: Plugins using the `onPageRendered` event
  380. *
  381. * Please note that Pico will change the processing order when needed to
  382. * incorporate plugin dependencies. See {@see Pico::sortPlugins()} for
  383. * details.
  384. *
  385. * @see Pico::loadPlugin()
  386. * @see Pico::getPlugin()
  387. * @see Pico::getPlugins()
  388. * @return void
  389. * @throws RuntimeException thrown when a plugin couldn't be loaded
  390. */
  391. protected function loadPlugins()
  392. {
  393. $this->plugins = array();
  394. // discover plugin files
  395. $pluginFiles = array();
  396. $files = scandir($this->getPluginsDir());
  397. if ($files !== false) {
  398. foreach ($files as $file) {
  399. if ($file[0] === '.') {
  400. continue;
  401. }
  402. if (is_dir($this->getPluginsDir() . $file)) {
  403. $className = preg_replace('/^[0-9]+-/', '', $file);
  404. $subdirFiles = $this->getFilesGlob($this->getPluginsDir() . $file . '/?*.php', self::SORT_NONE);
  405. foreach ($subdirFiles as $subdirFile) {
  406. $subdirFile = basename($subdirFile, '.php');
  407. if (strcasecmp($className, $subdirFile) === 0) {
  408. $pluginFiles[$className] = $file . '/' . $subdirFile . '.php';
  409. }
  410. }
  411. if (!isset($pluginFiles[$className])) {
  412. throw new RuntimeException(
  413. "Unable to load plugin '" . $className . "' from "
  414. . "'" . $file . "/" . $className . ".php': File not found"
  415. );
  416. }
  417. } elseif (substr($file, -4) === '.php') {
  418. $className = preg_replace('/^[0-9]+-/', '', substr($file, 0, -4));
  419. $pluginFiles[$className] = $file;
  420. }
  421. }
  422. }
  423. // scope isolated require_once()
  424. $includeClosure = function ($pluginFile) {
  425. require_once($pluginFile);
  426. };
  427. if (PHP_VERSION_ID >= 50400) {
  428. $includeClosure = $includeClosure->bindTo(null);
  429. }
  430. foreach ($pluginFiles as $className => $pluginFile) {
  431. $includeClosure($this->getPluginsDir() . $pluginFile);
  432. if (class_exists($className, false)) {
  433. // class name and file name can differ regarding case sensitivity
  434. $plugin = new $className($this);
  435. $className = get_class($plugin);
  436. $this->plugins[$className] = $plugin;
  437. } else {
  438. throw new RuntimeException(
  439. "Unable to load plugin '" . $className . "' "
  440. . "from '" . $pluginFile . "'"
  441. );
  442. }
  443. }
  444. }
  445. /**
  446. * Manually loads a plugin
  447. *
  448. * Manually loaded plugins must implement {@see PicoPluginInterface}.
  449. *
  450. * @see Pico::loadPlugins()
  451. * @see Pico::getPlugin()
  452. * @see Pico::getPlugins()
  453. * @param PicoPluginInterface|string $plugin either the class name of a
  454. * plugin to instantiate or a plugin instance
  455. * @return PicoPluginInterface instance of the loaded plugin
  456. * @throws RuntimeException thrown when a plugin couldn't
  457. * be loaded
  458. */
  459. public function loadPlugin($plugin)
  460. {
  461. if (!is_object($plugin)) {
  462. $className = (string) $plugin;
  463. if (class_exists($className)) {
  464. $plugin = new $className($this);
  465. } else {
  466. throw new RuntimeException("Unable to load plugin '" . $className . "': Class not found");
  467. }
  468. }
  469. $className = get_class($plugin);
  470. if (!($plugin instanceof PicoPluginInterface)) {
  471. throw new RuntimeException(
  472. "Unable to load plugin '" . $className . "': "
  473. . "Manually loaded plugins must implement 'PicoPluginInterface'"
  474. );
  475. }
  476. if ($this->plugins === null) {
  477. $this->plugins = array();
  478. }
  479. $this->plugins[$className] = $plugin;
  480. return $plugin;
  481. }
  482. /**
  483. * Sorts all loaded plugins using a plugin dependency topology
  484. *
  485. * Execution order matters: if plugin A depends on plugin B, it usually
  486. * means that plugin B does stuff which plugin A requires. However, Pico
  487. * loads plugins in alphabetical order, so events might get fired on
  488. * plugin A before plugin B.
  489. *
  490. * Hence plugins need to be sorted. Pico sorts plugins using a dependency
  491. * topology, this means that it moves all plugins, on which a plugin
  492. * depends, in front of that plugin. The order isn't touched apart from
  493. * that, so they are still sorted alphabetically, as long as this doesn't
  494. * interfere with the dependency topology. Circular dependencies are being
  495. * ignored; their behavior is undefiend. Missing dependencies are being
  496. * ignored until you try to enable the dependant plugin.
  497. *
  498. * This method bases on Marc J. Schmidt's Topological Sort library in
  499. * version 1.1.0, licensed under the MIT license. It uses the `ArraySort`
  500. * implementation (class `\MJS\TopSort\Implementations\ArraySort`).
  501. *
  502. * @see Pico::loadPlugins()
  503. * @see Pico::getPlugins()
  504. * @see https://github.com/marcj/topsort.php
  505. * Marc J. Schmidt's Topological Sort / Dependency resolver in PHP
  506. * @see https://github.com/marcj/topsort.php/blob/1.1.0/src/Implementations/ArraySort.php
  507. * \MJS\TopSort\Implementations\ArraySort class
  508. * @return void
  509. */
  510. protected function sortPlugins()
  511. {
  512. $plugins = $this->plugins;
  513. $sortedPlugins = array();
  514. $visitedPlugins = array();
  515. $visitPlugin = function ($plugin) use ($plugins, &$sortedPlugins, &$visitedPlugins, &$visitPlugin) {
  516. $pluginName = get_class($plugin);
  517. // skip already visited plugins and ignore circular dependencies
  518. if (!isset($visitedPlugins[$pluginName])) {
  519. $visitedPlugins[$pluginName] = true;
  520. $dependencies = array();
  521. if ($plugin instanceof PicoPluginInterface) {
  522. $dependencies = $plugin->getDependencies();
  523. } else {
  524. $dependencies = array('PicoDeprecated');
  525. }
  526. foreach ($dependencies as $dependency) {
  527. // ignore missing dependencies
  528. // this is only a problem when the user tries to enable this plugin
  529. if (isset($plugins[$dependency])) {
  530. $visitPlugin($plugins[$dependency]);
  531. }
  532. }
  533. $sortedPlugins[$pluginName] = $plugin;
  534. }
  535. };
  536. foreach ($this->plugins as $plugin) {
  537. $visitPlugin($plugin);
  538. }
  539. $this->plugins = $sortedPlugins;
  540. }
  541. /**
  542. * Returns the instance of a named plugin
  543. *
  544. * Plugins SHOULD implement {@link PicoPluginInterface}, but you MUST NOT
  545. * rely on it. For more information see {@link PicoPluginInterface}.
  546. *
  547. * @see Pico::loadPlugins()
  548. * @see Pico::getPlugins()
  549. * @param string $pluginName name of the plugin
  550. * @return object instance of the plugin
  551. * @throws RuntimeException thrown when the plugin wasn't found
  552. */
  553. public function getPlugin($pluginName)
  554. {
  555. if (isset($this->plugins[$pluginName])) {
  556. return $this->plugins[$pluginName];
  557. }
  558. throw new RuntimeException("Missing plugin '" . $pluginName . "'");
  559. }
  560. /**
  561. * Returns all loaded plugins
  562. *
  563. * @see Pico::loadPlugins()
  564. * @see Pico::getPlugin()
  565. * @return object[]|null
  566. */
  567. public function getPlugins()
  568. {
  569. return $this->plugins;
  570. }
  571. /**
  572. * Loads the config.php and any *.config.php from Pico::$configDir
  573. *
  574. * After loading the {@path "config/config.php"}, Pico proceeds with any
  575. * existing `config/*.config.php` in alphabetical order. The file order is
  576. * crucial: Config values which has been set already, cannot be overwritten
  577. * by a succeeding file. This is also true for arrays, i.e. when specifying
  578. * `$config['test'] = array('foo' => 'bar')` in `config/a.config.php` and
  579. * `$config['test'] = array('baz' => 42)` in `config/b.config.php`,
  580. * `$config['test']['baz']` will be undefined!
  581. *
  582. * @see Pico::setConfig()
  583. * @see Pico::getConfig()
  584. * @return void
  585. */
  586. protected function loadConfig()
  587. {
  588. // load config closure
  589. $yamlParser = $this->getYamlParser();
  590. $loadConfigClosure = function ($configFile) use ($yamlParser) {
  591. $yaml = file_get_contents($configFile);
  592. $config = $yamlParser->parse($yaml);
  593. return is_array($config) ? $config : array();
  594. };
  595. // load main config file (config/config.yml)
  596. $this->config = is_array($this->config) ? $this->config : array();
  597. if (file_exists($this->getConfigDir() . 'config.yml')) {
  598. $this->config += $loadConfigClosure($this->getConfigDir() . 'config.yml');
  599. }
  600. // merge $config of config/*.yml files
  601. $configFiles = $this->getFilesGlob($this->getConfigDir() . '*.yml');
  602. foreach ($configFiles as $configFile) {
  603. if ($configFile !== 'config.yml') {
  604. $this->config += $loadConfigClosure($configFile);
  605. }
  606. }
  607. // merge default config
  608. $this->config += array(
  609. 'site_title' => 'Pico',
  610. 'base_url' => '',
  611. 'rewrite_url' => null,
  612. 'timezone' => null,
  613. 'theme' => 'default',
  614. 'theme_url' => null,
  615. 'twig_config' => null,
  616. 'date_format' => '%D %T',
  617. 'pages_order_by' => 'alpha',
  618. 'pages_order' => 'asc',
  619. 'content_dir' => null,
  620. 'content_ext' => '.md',
  621. 'content_config' => null
  622. );
  623. if (!$this->config['base_url']) {
  624. $this->config['base_url'] = $this->getBaseUrl();
  625. } else {
  626. $this->config['base_url'] = rtrim($this->config['base_url'], '/') . '/';
  627. }
  628. if ($this->config['rewrite_url'] === null) {
  629. $this->config['rewrite_url'] = $this->isUrlRewritingEnabled();
  630. }
  631. if (!$this->config['timezone']) {
  632. // explicitly set a default timezone to prevent a E_NOTICE
  633. // when no timezone is set; the `date_default_timezone_get()`
  634. // function always returns a timezone, at least UTC
  635. $this->config['timezone'] = @date_default_timezone_get();
  636. }
  637. date_default_timezone_set($this->config['timezone']);
  638. if (!$this->config['theme_url']) {
  639. $this->config['theme_url'] = $this->getBaseThemeUrl();
  640. } elseif (preg_match('#^[A-Za-z][A-Za-z0-9+\-.]*://#', $this->config['theme_url'])) {
  641. $this->config['theme_url'] = rtrim($this->config['theme_url'], '/') . '/';
  642. } else {
  643. $this->config['theme_url'] = $this->getBaseUrl() . rtrim($this->config['theme_url'], '/') . '/';
  644. }
  645. $defaultTwigConfig = array('cache' => false, 'autoescape' => false, 'debug' => false);
  646. if (!is_array($this->config['twig_config'])) {
  647. $this->config['twig_config'] = $defaultTwigConfig;
  648. } else {
  649. $this->config['twig_config'] += $defaultTwigConfig;
  650. }
  651. if (!$this->config['content_dir']) {
  652. // try to guess the content directory
  653. if (is_dir($this->getRootDir() . 'content')) {
  654. $this->config['content_dir'] = $this->getRootDir() . 'content/';
  655. } else {
  656. $this->config['content_dir'] = $this->getRootDir() . 'content-sample/';
  657. }
  658. } else {
  659. $this->config['content_dir'] = $this->getAbsolutePath($this->config['content_dir']);
  660. }
  661. $defaultContentConfig = array('extra' => true, 'breaks' => false, 'escape' => false, 'auto_urls' => true);
  662. if (!is_array($this->config['content_config'])) {
  663. $this->config['content_config'] = $defaultContentConfig;
  664. } else {
  665. $this->config['content_config'] += $defaultContentConfig;
  666. }
  667. }
  668. /**
  669. * Sets Pico's config before calling Pico::run()
  670. *
  671. * This method allows you to modify Pico's config without creating a
  672. * {@path "config/config.php"} or changing some of its variables before
  673. * Pico starts processing.
  674. *
  675. * You can call this method between {@link Pico::__construct()} and
  676. * {@link Pico::run()} only. Options set with this method cannot be
  677. * overwritten by {@path "config/config.php"}.
  678. *
  679. * @see Pico::loadConfig()
  680. * @see Pico::getConfig()
  681. * @param array $config array with config variables
  682. * @return void
  683. * @throws LogicException thrown if Pico already started processing
  684. */
  685. public function setConfig(array $config)
  686. {
  687. if ($this->locked) {
  688. throw new LogicException("You cannot modify Pico's config after processing has started");
  689. }
  690. $this->config = $config;
  691. }
  692. /**
  693. * Returns either the value of the specified config variable or
  694. * the config array
  695. *
  696. * @see Pico::setConfig()
  697. * @see Pico::loadConfig()
  698. * @param string $configName optional name of a config variable
  699. * @return mixed returns either the value of the named config
  700. * variable, null if the config variable doesn't exist or the config
  701. * array if no config name was supplied
  702. */
  703. public function getConfig($configName = null)
  704. {
  705. if ($configName !== null) {
  706. return isset($this->config[$configName]) ? $this->config[$configName] : null;
  707. } else {
  708. return $this->config;
  709. }
  710. }
  711. /**
  712. * Evaluates the requested URL
  713. *
  714. * Pico uses the `QUERY_STRING` routing method (e.g. `/pico/?sub/page`)
  715. * to support SEO-like URLs out-of-the-box with any webserver. You can
  716. * still setup URL rewriting to basically remove the `?` from URLs.
  717. * However, URL rewriting requires some special configuration of your
  718. * webserver, but this should be "basic work" for any webmaster...
  719. *
  720. * With Pico 1.0 you had to setup URL rewriting (e.g. using `mod_rewrite`
  721. * on Apache) in a way that rewritten URLs follow the `QUERY_STRING`
  722. * principles. Starting with version 2.0, Pico additionally supports the
  723. * `REQUEST_URI` routing method, what allows you to simply rewrite all
  724. * requests to just `index.php`. Pico then reads the requested page from
  725. * the `REQUEST_URI` environment variable provided by the webserver.
  726. * Please note that `QUERY_STRING` takes precedence over `REQUEST_URI`.
  727. *
  728. * Pico 0.9 and older required Apache with `mod_rewrite` enabled, thus old
  729. * plugins, templates and contents may require you to enable URL rewriting
  730. * to work. If you're upgrading from Pico 0.9, you will probably have to
  731. * update your rewriting rules.
  732. *
  733. * We recommend you to use the `link` filter in templates to create
  734. * internal links, e.g. `{{ "sub/page"|link }}` is equivalent to
  735. * `{{ base_url }}/sub/page` and `{{ base_url }}?sub/page`, depending on
  736. * enabled URL rewriting. In content files you can use the `%base_url%`
  737. * variable; e.g. `%base_url%?sub/page` will be replaced accordingly.
  738. *
  739. * Heads up! Pico always interprets the first parameter as name of the
  740. * requested page (provided that the parameter has no value). According to
  741. * that you MUST NOT call Pico with a parameter without value as first
  742. * parameter (e.g. http://example.com/pico/?someBooleanParam), otherwise
  743. * Pico interprets `someBooleanParam` as name of the requested page. Use
  744. * `/pico/?someBooleanParam=` or `/pico/?index&someBooleanParam` instead.
  745. *
  746. * @see Pico::getRequestUrl()
  747. * @return void
  748. */
  749. protected function evaluateRequestUrl()
  750. {
  751. // use QUERY_STRING; e.g. /pico/?sub/page
  752. $pathComponent = isset($_SERVER['QUERY_STRING']) ? $_SERVER['QUERY_STRING'] : '';
  753. if ($pathComponent) {
  754. $pathComponent = strstr($pathComponent, '&', true) ?: $pathComponent;
  755. if (strpos($pathComponent, '=') === false) {
  756. $this->requestUrl = trim(rawurldecode($pathComponent), '/');
  757. }
  758. }
  759. // use REQUEST_URI (requires URL rewriting); e.g. /pico/sub/page
  760. if (($this->requestUrl === null) && $this->isUrlRewritingEnabled()) {
  761. $basePath = dirname($_SERVER['SCRIPT_NAME']) . '/';
  762. $basePathLength = strlen($basePath);
  763. $requestUri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '';
  764. if ($requestUri && (substr($requestUri, 0, $basePathLength) === $basePath)) {
  765. $requestUri = substr($requestUri, $basePathLength);
  766. $requestUri = strstr($requestUri, '?', true) ?: $requestUri;
  767. $this->requestUrl = rtrim(rawurldecode($requestUri), '/');
  768. }
  769. }
  770. }
  771. /**
  772. * Returns the URL where a user requested the page
  773. *
  774. * @see Pico::evaluateRequestUrl()
  775. * @return string|null request URL
  776. */
  777. public function getRequestUrl()
  778. {
  779. return $this->requestUrl;
  780. }
  781. /**
  782. * Resolves a given file path to its corresponding content file
  783. *
  784. * This method also prevents `content_dir` breakouts using malicious
  785. * request URLs. We don't use `realpath()`, because we neither want to
  786. * check for file existance, nor prohibit symlinks which intentionally
  787. * point to somewhere outside the `content_dir` folder. It is STRONGLY
  788. * RECOMMENDED to use PHP's `open_basedir` feature - always, not just
  789. * with Pico!
  790. *
  791. * @see Pico::getRequestFile()
  792. * @param string $requestUrl path name (likely from a URL) to resolve
  793. * @return string path to the resolved content file
  794. */
  795. public function resolveFilePath($requestUrl)
  796. {
  797. $contentDir = $this->getConfig('content_dir');
  798. $contentExt = $this->getConfig('content_ext');
  799. if (!$requestUrl) {
  800. return $contentDir . 'index' . $contentExt;
  801. } else {
  802. // prevent content_dir breakouts
  803. $requestUrl = str_replace('\\', '/', $requestUrl);
  804. $requestUrlParts = explode('/', $requestUrl);
  805. $requestFileParts = array();
  806. foreach ($requestUrlParts as $requestUrlPart) {
  807. if (($requestUrlPart === '') || ($requestUrlPart === '.')) {
  808. continue;
  809. } elseif ($requestUrlPart === '..') {
  810. array_pop($requestFileParts);
  811. continue;
  812. }
  813. $requestFileParts[] = $requestUrlPart;
  814. }
  815. if (!$requestFileParts) {
  816. return $contentDir . 'index' . $contentExt;
  817. }
  818. // discover the content file to serve
  819. // Note: $requestFileParts neither contains a trailing nor a leading slash
  820. $requestFile = $contentDir . implode('/', $requestFileParts);
  821. if (is_dir($requestFile)) {
  822. // if no index file is found, try a accordingly named file in the previous dir
  823. // if this file doesn't exist either, show the 404 page, but assume the index
  824. // file as being requested (maintains backward compatibility to Pico < 1.0)
  825. $indexFile = $requestFile . '/index' . $contentExt;
  826. if (file_exists($indexFile) || !file_exists($requestFile . $contentExt)) {
  827. return $indexFile;
  828. }
  829. }
  830. return $requestFile . $contentExt;
  831. }
  832. }
  833. /**
  834. * Returns the absolute path to the content file to serve
  835. *
  836. * @see Pico::resolveFilePath()
  837. * @return string|null file path
  838. */
  839. public function getRequestFile()
  840. {
  841. return $this->requestFile;
  842. }
  843. /**
  844. * Returns the raw contents of a file
  845. *
  846. * @see Pico::getRawContent()
  847. * @param string $file file path
  848. * @return string raw contents of the file
  849. */
  850. public function loadFileContent($file)
  851. {
  852. return file_get_contents($file);
  853. }
  854. /**
  855. * Returns the raw contents of the first found 404 file when traversing
  856. * up from the directory the requested file is in
  857. *
  858. * If no suitable `404.md` is found, fallback to a built-in error message.
  859. *
  860. * @see Pico::getRawContent()
  861. * @param string $file path to requested (but not existing) file
  862. * @return string raw contents of the 404 file
  863. */
  864. public function load404Content($file)
  865. {
  866. $contentDir = $this->getConfig('content_dir');
  867. $contentDirLength = strlen($contentDir);
  868. $contentExt = $this->getConfig('content_ext');
  869. if (substr($file, 0, $contentDirLength) === $contentDir) {
  870. $errorFileDir = substr($file, $contentDirLength);
  871. while ($errorFileDir !== '.') {
  872. $errorFileDir = dirname($errorFileDir);
  873. $errorFile = $errorFileDir . '/404' . $contentExt;
  874. if (file_exists($contentDir . $errorFile)) {
  875. return $this->loadFileContent($contentDir . $errorFile);
  876. }
  877. }
  878. } elseif (file_exists($contentDir . '404' . $contentExt)) {
  879. // provided that the requested file is not in the regular
  880. // content directory, fallback to Pico's global `404.md`
  881. return $this->loadFileContent($contentDir . '404' . $contentExt);
  882. }
  883. // fallback to built-in error message
  884. $rawErrorContent = "---\n"
  885. . "Title: Error 404\n"
  886. . "Robots: noindex,nofollow\n"
  887. . "---\n\n"
  888. . "# Error 404\n\n"
  889. . "Woops. Looks like this page doesn't exist.\n";
  890. return $rawErrorContent;
  891. }
  892. /**
  893. * Returns the raw contents, either of the requested or the 404 file
  894. *
  895. * @see Pico::loadFileContent()
  896. * @see Pico::load404Content()
  897. * @return string|null raw contents
  898. */
  899. public function getRawContent()
  900. {
  901. return $this->rawContent;
  902. }
  903. /**
  904. * Returns TRUE when Pico is serving a 404 page
  905. *
  906. * @see Pico::load404Content()
  907. * @return boolean TRUE if Pico is serving a 404 page, FALSE otherwise
  908. */
  909. public function is404Content()
  910. {
  911. return $this->is404Content;
  912. }
  913. /**
  914. * Returns known meta headers
  915. *
  916. * @return string[] known meta headers; the array value specifies the
  917. * YAML key to search for, the array key is later used to access the
  918. * found value
  919. */
  920. public function getMetaHeaders()
  921. {
  922. if ($this->metaHeaders !== null) {
  923. return $this->metaHeaders;
  924. }
  925. return array(
  926. 'title' => 'Title',
  927. 'description' => 'Description',
  928. 'author' => 'Author',
  929. 'date' => 'Date',
  930. 'robots' => 'Robots',
  931. 'template' => 'Template'
  932. );
  933. }
  934. /**
  935. * Returns the Symfony YAML parser
  936. *
  937. * @return \Symfony\Component\Yaml\Parser Symfony YAML parser
  938. */
  939. public function getYamlParser()
  940. {
  941. if ($this->yamlParser === null) {
  942. $this->yamlParser = new \Symfony\Component\Yaml\Parser();
  943. }
  944. return $this->yamlParser;
  945. }
  946. /**
  947. * Parses the file meta from raw file contents
  948. *
  949. * Meta data MUST start on the first line of the file, either opened and
  950. * closed by `---` or C-style block comments (deprecated). The headers are
  951. * parsed by the YAML component of the Symfony project, keys are lowered.
  952. * If you're a plugin developer, you MUST register new headers during the
  953. * `onMetaHeaders` event first. The implicit availability of headers is
  954. * for users and pure (!) theme developers ONLY.
  955. *
  956. * @see Pico::getFileMeta()
  957. * @param string $rawContent the raw file contents
  958. * @param string[] $headers known meta headers
  959. * @return array parsed meta data
  960. * @throws \Symfony\Component\Yaml\Exception\ParseException thrown when the
  961. * meta data is invalid
  962. */
  963. public function parseFileMeta($rawContent, array $headers)
  964. {
  965. $meta = array();
  966. $pattern = "/^(\/(\*)|---)[[:blank:]]*(?:\r)?\n"
  967. . "(?:(.*?)(?:\r)?\n)?(?(2)\*\/|---)[[:blank:]]*(?:(?:\r)?\n|$)/s";
  968. if (preg_match($pattern, $rawContent, $rawMetaMatches) && isset($rawMetaMatches[3])) {
  969. $meta = $this->getYamlParser()->parse($rawMetaMatches[3]);
  970. if ($meta !== null) {
  971. // the parser may return a string for non-YAML 1-liners
  972. // assume that this string is the page title
  973. $meta = is_array($meta) ? array_change_key_case($meta, CASE_LOWER) : array('title' => $meta);
  974. } else {
  975. $meta = array();
  976. }
  977. foreach ($headers as $fieldId => $fieldName) {
  978. $fieldName = strtolower($fieldName);
  979. if (isset($meta[$fieldName])) {
  980. // rename field (e.g. remove whitespaces)
  981. if ($fieldId != $fieldName) {
  982. $meta[$fieldId] = $meta[$fieldName];
  983. unset($meta[$fieldName]);
  984. }
  985. } elseif (!isset($meta[$fieldId])) {
  986. // guarantee array key existance
  987. $meta[$fieldId] = '';
  988. }
  989. }
  990. if (!empty($meta['date'])) {
  991. // workaround for issue #336
  992. // Symfony YAML interprets ISO-8601 datetime strings and returns timestamps instead of the string
  993. // this behavior conforms to the YAML standard, i.e. this is no bug of Symfony YAML
  994. if (is_int($meta['date'])) {
  995. $meta['time'] = $meta['date'];
  996. $rawDateFormat = (date('H:i:s', $meta['time']) === '00:00:00') ? 'Y-m-d' : 'Y-m-d H:i:s';
  997. $meta['date'] = date($rawDateFormat, $meta['time']);
  998. } else {
  999. $meta['time'] = strtotime($meta['date']);
  1000. }
  1001. $meta['date_formatted'] = utf8_encode(strftime($this->getConfig('date_format'), $meta['time']));
  1002. } else {
  1003. $meta['date'] = $meta['time'] = $meta['date_formatted'] = '';
  1004. }
  1005. } else {
  1006. // guarantee array key existance
  1007. $meta = array_fill_keys(array_keys($headers), '');
  1008. $meta['time'] = $meta['date_formatted'] = '';
  1009. }
  1010. return $meta;
  1011. }
  1012. /**
  1013. * Returns the parsed meta data of the requested page
  1014. *
  1015. * @see Pico::parseFileMeta()
  1016. * @return array|null parsed meta data
  1017. */
  1018. public function getFileMeta()
  1019. {
  1020. return $this->meta;
  1021. }
  1022. /**
  1023. * Returns the Parsedown Extra markdown parser
  1024. *
  1025. * @return ParsedownExtra Parsedown Extra markdown parser
  1026. */
  1027. public function getParsedown()
  1028. {
  1029. if ($this->parsedown === null) {
  1030. $className = $this->config['content_config']['extra'] ? 'ParsedownExtra' : 'Parsedown';
  1031. $this->parsedown = new $className();
  1032. $this->parsedown->setBreaksEnabled((bool) $this->config['content_config']['breaks']);
  1033. $this->parsedown->setMarkupEscaped((bool) $this->config['content_config']['escape']);
  1034. $this->parsedown->setUrlsLinked((bool) $this->config['content_config']['auto_urls']);
  1035. }
  1036. return $this->parsedown;
  1037. }
  1038. /**
  1039. * Applies some static preparations to the raw contents of a page,
  1040. * e.g. removing the meta header and replacing %base_url%
  1041. *
  1042. * @see Pico::parseFileContent()
  1043. * @see Pico::getFileContent()
  1044. * @param string $rawContent raw contents of a page
  1045. * @param array $meta meta data to use for %meta.*% replacement
  1046. * @return string contents prepared for parsing
  1047. */
  1048. public function prepareFileContent($rawContent, array $meta)
  1049. {
  1050. $variables = array();
  1051. // remove meta header
  1052. $metaHeaderPattern = "/^(\/(\*)|---)[[:blank:]]*(?:\r)?\n"
  1053. . "(?:(.*?)(?:\r)?\n)?(?(2)\*\/|---)[[:blank:]]*(?:(?:\r)?\n|$)/s";
  1054. $content = preg_replace($metaHeaderPattern, '', $rawContent, 1);
  1055. // replace %version%
  1056. $variables['%version%'] = static::VERSION;
  1057. // replace %site_title%
  1058. $variables['%site_title%'] = $this->getConfig('site_title');
  1059. // replace %base_url%
  1060. if ($this->isUrlRewritingEnabled()) {
  1061. // always use `%base_url%?sub/page` syntax for internal links
  1062. // we'll replace the links accordingly, depending on enabled rewriting
  1063. $variables['%base_url%?'] = $this->getBaseUrl();
  1064. } else {
  1065. // actually not necessary, but makes the URL look a little nicer
  1066. $variables['%base_url%?'] = $this->getBaseUrl() . '?';
  1067. }
  1068. $variables['%base_url%'] = rtrim($this->getBaseUrl(), '/');
  1069. // replace %theme_url%
  1070. $variables['%theme_url%'] = $this->getBaseThemeUrl() . $this->getConfig('theme');
  1071. // replace %meta.*%
  1072. if ($meta) {
  1073. foreach ($meta as $metaKey => $metaValue) {
  1074. if (is_scalar($metaValue) || ($metaValue === null)) {
  1075. $variables['%meta.' . $metaKey . '%'] = strval($metaValue);
  1076. }
  1077. }
  1078. }
  1079. $content = str_replace(array_keys($variables), $variables, $content);
  1080. return $content;
  1081. }
  1082. /**
  1083. * Parses the contents of a page using ParsedownExtra
  1084. *
  1085. * @see Pico::prepareFileContent()
  1086. * @see Pico::getFileContent()
  1087. * @param string $content raw contents of a page (Markdown)
  1088. * @return string parsed contents (HTML)
  1089. */
  1090. public function parseFileContent($content)
  1091. {
  1092. return $this->getParsedown()->text($content);
  1093. }
  1094. /**
  1095. * Returns the cached contents of the requested page
  1096. *
  1097. * @see Pico::prepareFileContent()
  1098. * @see Pico::parseFileContent()
  1099. * @return string|null parsed contents
  1100. */
  1101. public function getFileContent()
  1102. {
  1103. return $this->content;
  1104. }
  1105. /**
  1106. * Reads the data of all pages known to Pico
  1107. *
  1108. * The page data will be an array containing the following values:
  1109. *
  1110. * | Array key | Type | Description |
  1111. * | -------------- | ------ | ---------------------------------------- |
  1112. * | id | string | relative path to the content file |
  1113. * | url | string | URL to the page |
  1114. * | title | string | title of the page (YAML header) |
  1115. * | description | string | description of the page (YAML header) |
  1116. * | author | string | author of the page (YAML header) |
  1117. * | time | string | timestamp derived from the Date header |
  1118. * | date | string | date of the page (YAML header) |
  1119. * | date_formatted | string | formatted date of the page |
  1120. * | raw_content | string | raw, not yet parsed contents of the page |
  1121. * | meta | string | parsed meta data of the page |
  1122. * | previous_page | &array | reference to the previous page |
  1123. * | next_page | &array | reference to the next page |
  1124. *
  1125. * @see Pico::sortPages()
  1126. * @see Pico::discoverPageSiblings()
  1127. * @see Pico::getPages()
  1128. * @return void
  1129. */
  1130. protected function readPages()
  1131. {
  1132. $contentDir = $this->getConfig('content_dir');
  1133. $contentDirLength = strlen($contentDir);
  1134. $contentExt = $this->getConfig('content_ext');
  1135. $contentExtLength = strlen($contentExt);
  1136. $this->pages = array();
  1137. $files = $this->getFiles($contentDir, $contentExt, Pico::SORT_NONE);
  1138. foreach ($files as $i => $file) {
  1139. // skip 404 page
  1140. if (basename($file) === '404' . $contentExt) {
  1141. unset($files[$i]);
  1142. continue;
  1143. }
  1144. $id = substr($file, $contentDirLength, -$contentExtLength);
  1145. // trigger onSinglePageLoading event
  1146. $this->triggerEvent('onSinglePageLoading', array(&$id));
  1147. // drop inaccessible pages (e.g. drop "sub.md" if "sub/index.md" exists)
  1148. $conflictFile = $contentDir . $id . '/index' . $contentExt;
  1149. if (in_array($conflictFile, $files, true)) {
  1150. continue;
  1151. }
  1152. $url = $this->getPageUrl($id);
  1153. if ($file !== $this->requestFile) {
  1154. $rawContent = $this->loadFileContent($file);
  1155. $headers = $this->getMetaHeaders();
  1156. try {
  1157. $meta = $this->parseFileMeta($rawContent, $headers);
  1158. } catch (\Symfony\Component\Yaml\Exception\ParseException $e) {
  1159. $meta = $this->parseFileMeta('', $headers);
  1160. $meta['YAML_ParseError'] = $e->getMessage();
  1161. }
  1162. } else {
  1163. $rawContent = &$this->rawContent;
  1164. $meta = &$this->meta;
  1165. }
  1166. // build page data
  1167. // title, description, author and date are assumed to be pretty basic data
  1168. // everything else is accessible through $page['meta']
  1169. $page = array(
  1170. 'id' => $id,
  1171. 'url' => $url,
  1172. 'title' => &$meta['title'],
  1173. 'description' => &$meta['description'],
  1174. 'author' => &$meta['author'],
  1175. 'time' => &$meta['time'],
  1176. 'date' => &$meta['date'],
  1177. 'date_formatted' => &$meta['date_formatted'],
  1178. 'hidden' => (bool) preg_match('/(?:^|\/)_/', $id),
  1179. 'raw_content' => &$rawContent,
  1180. 'meta' => &$meta
  1181. );
  1182. if ($file === $this->requestFile) {
  1183. $page['content'] = &$this->content;
  1184. }
  1185. unset($rawContent, $meta);
  1186. // trigger onSinglePageLoaded event
  1187. $this->triggerEvent('onSinglePageLoaded', array(&$page));
  1188. if ($page !== null) {
  1189. $this->pages[$id] = $page;
  1190. }
  1191. }
  1192. }
  1193. /**
  1194. * Sorts all pages known to Pico
  1195. *
  1196. * @see Pico::readPages()
  1197. * @see Pico::getPages()
  1198. * @return void
  1199. */
  1200. protected function sortPages()
  1201. {
  1202. // sort pages
  1203. $order = strtolower($this->getConfig('pages_order'));
  1204. $orderBy = strtolower($this->getConfig('pages_order_by'));
  1205. if (($orderBy !== 'date') && ($orderBy !== 'alpha')) {
  1206. return;
  1207. }
  1208. $alphaSortClosure = function ($a, $b) use ($order) {
  1209. if ($a['hidden'] xor $b['hidden']) {
  1210. return (!!$a['hidden'] - !!$b['hidden']) * (($order === 'desc') ? -1 : 1);
  1211. }
  1212. $aSortKey = (basename($a['id']) === 'index') ? dirname($a['id']) : $a['id'];
  1213. $bSortKey = (basename($b['id']) === 'index') ? dirname($b['id']) : $b['id'];
  1214. $cmp = strcmp($aSortKey, $bSortKey);
  1215. return $cmp * (($order === 'desc') ? -1 : 1);
  1216. };
  1217. if ($orderBy === 'date') {
  1218. // sort by date
  1219. uasort($this->pages, function ($a, $b) use ($alphaSortClosure, $order) {
  1220. if ($a['hidden'] xor $b['hidden']) {
  1221. return $alphaSortClosure($a, $b);
  1222. }
  1223. if (!$a['time'] || !$b['time']) {
  1224. $cmp = (!$a['time'] - !$b['time']);
  1225. } else {
  1226. $cmp = ($b['time'] - $a['time']);
  1227. }
  1228. if ($cmp === 0) {
  1229. // never assume equality; fallback to alphabetical order
  1230. return $alphaSortClosure($a, $b);
  1231. }
  1232. return $cmp * (($order === 'desc') ? 1 : -1);
  1233. });
  1234. } else {
  1235. // sort alphabetically
  1236. uasort($this->pages, $alphaSortClosure);
  1237. }
  1238. }
  1239. /**
  1240. * Walks through the list of all known pages and discovers the previous and
  1241. * next page respectively
  1242. *
  1243. * @see Pico::readPages()
  1244. * @see Pico::getPages()
  1245. * @return void
  1246. */
  1247. protected function discoverPageSiblings()
  1248. {
  1249. if (($this->getConfig('order_by') === 'date') && ($this->getConfig('order') === 'desc')) {
  1250. $precedingPageKey = 'next_page';
  1251. $succeedingPageKey = 'previous_page';
  1252. } else {
  1253. $precedingPageKey = 'previous_page';
  1254. $succeedingPageKey = 'next_page';
  1255. }
  1256. $precedingPageId = null;
  1257. foreach ($this->pages as $id => &$pageData) {
  1258. $pageData[$precedingPageKey] = null;
  1259. $pageData[$succeedingPageKey] = null;
  1260. if ($pageData['hidden']) {
  1261. continue;
  1262. }
  1263. if ($precedingPageId !== null) {
  1264. $precedingPageData = &$this->pages[$precedingPageId];
  1265. $pageData[$precedingPageKey] = &$precedingPageData;
  1266. $precedingPageData[$succeedingPageKey] = &$pageData;
  1267. }
  1268. $precedingPageId = $id;
  1269. }
  1270. }
  1271. /**
  1272. * Returns the list of known pages
  1273. *
  1274. * @see Pico::readPages()
  1275. * @return array[]|null the data of all pages
  1276. */
  1277. public function getPages()
  1278. {
  1279. return $this->pages;
  1280. }
  1281. /**
  1282. * Discovers the page data of the requested page as well as the previous
  1283. * and next page relative to it
  1284. *
  1285. * @see Pico::getCurrentPage()
  1286. * @see Pico::getPreviousPage()
  1287. * @see Pico::getNextPage()
  1288. * @return void
  1289. */
  1290. protected function discoverCurrentPage()
  1291. {
  1292. $contentDir = $this->getConfig('content_dir');
  1293. $contentDirLength = strlen($contentDir);
  1294. // the requested file is not in the regular content directory, therefore its ID
  1295. // isn't specified and it's impossible to determine the current page automatically
  1296. if (substr($this->requestFile, 0, $contentDirLength) !== $contentDir) {
  1297. return;
  1298. }
  1299. $currentPageId = substr($this->requestFile, $contentDirLength, -strlen($this->getConfig('content_ext')));
  1300. if (isset($this->pages[$currentPageId])) {
  1301. $this->currentPage = &$this->pages[$currentPageId];
  1302. $this->previousPage = &$this->pages[$currentPageId]['previous_page'];
  1303. $this->nextPage = &$this->pages[$currentPageId]['next_page'];
  1304. }
  1305. }
  1306. /**
  1307. * Returns the data of the requested page
  1308. *
  1309. * @see Pico::discoverCurrentPage()
  1310. * @return array|null page data
  1311. */
  1312. public function getCurrentPage()
  1313. {
  1314. return $this->currentPage;
  1315. }
  1316. /**
  1317. * Returns the data of the previous page relative to the page being served
  1318. *
  1319. * @see Pico::discoverCurrentPage()
  1320. * @return array|null page data
  1321. */
  1322. public function getPreviousPage()
  1323. {
  1324. return $this->previousPage;
  1325. }
  1326. /**
  1327. * Returns the data of the next page relative to the page being served
  1328. *
  1329. * @see Pico::discoverCurrentPage()
  1330. * @return array|null page data
  1331. */
  1332. public function getNextPage()
  1333. {
  1334. return $this->nextPage;
  1335. }
  1336. /**
  1337. * Registers the twig template engine
  1338. *
  1339. * This method also registers Pico's core Twig filters `link` and `content`
  1340. * as well as Pico's {@link PicoTwigExtension} Twig extension.
  1341. *
  1342. * @see Pico::getTwig()
  1343. * @see http://twig.sensiolabs.org/ Twig website
  1344. * @see https://github.com/twigphp/Twig Twig on GitHub
  1345. * @return void
  1346. */
  1347. protected function registerTwig()
  1348. {
  1349. if ($this->twig !== null) {
  1350. // nothing to do
  1351. return;
  1352. }
  1353. $twigLoader = new Twig_Loader_Filesystem($this->getThemesDir() . $this->getConfig('theme'));
  1354. $this->twig = new Twig_Environment($twigLoader, $this->getConfig('twig_config'));
  1355. $this->twig->addExtension(new Twig_Extension_Debug());
  1356. $this->twig->addExtension(new PicoTwigExtension($this));
  1357. // register content filter
  1358. // we pass the $pages array by reference to prevent multiple parser runs for the same page
  1359. // this is the reason why we can't register this filter as part of PicoTwigExtension
  1360. $pico = $this;
  1361. $pages = &$this->pages;
  1362. $this->twig->addFilter(new Twig_SimpleFilter('content', function ($page) use ($pico, &$pages) {
  1363. if (isset($pages[$page])) {
  1364. $pageData = &$pages[$page];
  1365. if (!isset($pageData['content'])) {
  1366. $pageData['content'] = $pico->prepareFileContent($pageData['raw_content'], $pageData['meta']);
  1367. $pageData['content'] = $pico->parseFileContent($pageData['content']);
  1368. }
  1369. return $pageData['content'];
  1370. }
  1371. return null;
  1372. }));
  1373. }
  1374. /**
  1375. * Returns the twig template engine
  1376. *
  1377. * @see Pico::registerTwig()
  1378. * @return Twig_Environment|null Twig template engine
  1379. */
  1380. public function getTwig()
  1381. {
  1382. if ($this->twig === null) {
  1383. $this->registerTwig();
  1384. }
  1385. return $this->twig;
  1386. }
  1387. /**
  1388. * Returns the variables passed to the template
  1389. *
  1390. * URLs and paths (namely `base_dir`, `base_url`, `theme_dir` and
  1391. * `theme_url`) don't add a trailing slash for historic reasons.
  1392. *
  1393. * @return array template variables
  1394. */
  1395. protected function getTwigVariables()
  1396. {
  1397. if ($this->twigVariables !== null) {
  1398. return $this->twigVariables;
  1399. }
  1400. return array(
  1401. 'config' => $this->getConfig(),
  1402. 'base_dir' => rtrim($this->getRootDir(), '/'),
  1403. 'base_url' => rtrim($this->getBaseUrl(), '/'),
  1404. 'theme_dir' => $this->getThemesDir() . $this->getConfig('theme'),
  1405. 'theme_url' => $this->getBaseThemeUrl() . $this->getConfig('theme'),
  1406. 'site_title' => $this->getConfig('site_title'),
  1407. 'meta' => $this->meta,
  1408. 'content' => $this->content,
  1409. 'pages' => $this->pages,
  1410. 'prev_page' => $this->previousPage,
  1411. 'current_page' => $this->currentPage,
  1412. 'next_page' => $this->nextPage,
  1413. 'version' => static::VERSION
  1414. );
  1415. }
  1416. /**
  1417. * Returns the name of the Twig template to render
  1418. *
  1419. * @return string template name
  1420. */
  1421. protected function getTwigTemplate()
  1422. {
  1423. if ($this->twigTemplate !== null) {
  1424. return $this->twigTemplate;
  1425. }
  1426. $templateName = $this->meta['template'] ?: 'index';
  1427. if (file_exists($this->getThemesDir() . $this->getConfig('theme') . '/' . $templateName . '.twig')) {
  1428. $templateName .= '.twig';
  1429. } else {
  1430. $templateName .= '.html';
  1431. }
  1432. return $templateName;
  1433. }
  1434. /**
  1435. * Returns the base URL of this Pico instance
  1436. *
  1437. * @return string the base url
  1438. */
  1439. public function getBaseUrl()
  1440. {
  1441. $baseUrl = $this->getConfig('base_url');
  1442. if ($baseUrl) {
  1443. return $baseUrl;
  1444. }
  1445. $protocol = 'http';
  1446. if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
  1447. $secureProxyHeader = strtolower(current(explode(',', $_SERVER['HTTP_X_FORWARDED_PROTO'])));
  1448. $protocol = in_array($secureProxyHeader, array('https', 'on', 'ssl', '1')) ? 'https' : 'http';
  1449. } elseif (!empty($_SERVER['HTTPS']) && ($_SERVER['HTTPS'] !== 'off')) {
  1450. $protocol = 'https';
  1451. } elseif ($_SERVER['SERVER_PORT'] == 443) {
  1452. $protocol = 'https';
  1453. }
  1454. $this->config['base_url'] =
  1455. $protocol . "://" . $_SERVER['HTTP_HOST']
  1456. . rtrim(dirname($_SERVER['SCRIPT_NAME']), '/\\') . '/';
  1457. return $this->config['base_url'];
  1458. }
  1459. /**
  1460. * Returns true if URL rewriting is enabled
  1461. *
  1462. * @return boolean true if URL rewriting is enabled, false otherwise
  1463. */
  1464. public function isUrlRewritingEnabled()
  1465. {
  1466. $urlRewritingEnabled = $this->getConfig('rewrite_url');
  1467. if ($urlRewritingEnabled !== null) {
  1468. return $urlRewritingEnabled;
  1469. }
  1470. $this->config['rewrite_url'] = (isset($_SERVER['PICO_URL_REWRITING']) && $_SERVER['PICO_URL_REWRITING']);
  1471. return $this->config['rewrite_url'];
  1472. }
  1473. /**
  1474. * Returns the URL to a given page
  1475. *
  1476. * This method can be used in Twig templates by applying the `link` filter
  1477. * to a string representing a page identifier.
  1478. *
  1479. * @param string $page identifier of the page to link to
  1480. * @param array|string $queryData either an array containing properties to
  1481. * create a URL-encoded query string from, or a already encoded string
  1482. * @param boolean $dropIndex when the last path component is "index",
  1483. * then passing TRUE (default) leads to removing this path component
  1484. * @return string URL
  1485. */
  1486. public function getPageUrl($page, $queryData = null, $dropIndex = true)
  1487. {
  1488. if (is_array($queryData)) {
  1489. $queryData = http_build_query($queryData, '', '&');
  1490. } elseif (($queryData !== null) && !is_string($queryData)) {
  1491. throw new InvalidArgumentException(
  1492. 'Argument 2 passed to ' . get_called_class() . '::getPageUrl() must be of the type array or string, '
  1493. . (is_object($queryData) ? get_class($queryData) : gettype($queryData)) . ' given'
  1494. );
  1495. }
  1496. // drop "index"
  1497. if ($dropIndex) {
  1498. if ($page === 'index') {
  1499. $page = '';
  1500. } elseif (($pagePathLength = strrpos($page, '/')) !== false) {
  1501. if (substr($page, $pagePathLength + 1) === 'index') {
  1502. $page = substr($page, 0, $pagePathLength);
  1503. }
  1504. }
  1505. }
  1506. if ($queryData) {
  1507. $queryData = ($this->isUrlRewritingEnabled() || !$page) ? '?' . $queryData : '&' . $queryData;
  1508. }
  1509. if (!$page) {
  1510. return $this->getBaseUrl() . $queryData;
  1511. } elseif (!$this->isUrlRewritingEnabled()) {
  1512. return $this->getBaseUrl() . '?' . rawurlencode($page) . $queryData;
  1513. } else {
  1514. return $this->getBaseUrl() . implode('/', array_map('rawurlencode', explode('/', $page))) . $queryData;
  1515. }
  1516. }
  1517. /**
  1518. * Returns the URL of the themes folder of this Pico instance
  1519. *
  1520. * We assume that the themes folder is a arbitrary deep sub folder of the
  1521. * script's base path (i.e. the directory {@path "index.php"} is in resp.
  1522. * the `httpdocs` directory). Usually the script's base path is identical
  1523. * to {@link Pico::$rootDir}, but this may aberrate when Pico got installed
  1524. * as a composer dependency. However, ultimately it allows us to use
  1525. * {@link Pico::getBaseUrl()} as origin of the theme URL. Otherwise Pico
  1526. * falls back to the basename of {@link Pico::$themesDir} (i.e. assuming
  1527. * that `Pico::$themesDir` is `foo/bar/baz`, the base URL of the themes
  1528. * folder will be `baz/`; this ensures BC to Pico < 2.0). Pico's base URL
  1529. * always gets prepended appropriately.
  1530. *
  1531. * @return string the URL of the themes folder
  1532. */
  1533. public function getBaseThemeUrl()
  1534. {
  1535. $themeUrl = $this->getConfig('theme_url');
  1536. if ($themeUrl) {
  1537. return $themeUrl;
  1538. }
  1539. $basePath = dirname($_SERVER['SCRIPT_FILENAME']) . '/';
  1540. $basePathLength = strlen($basePath);
  1541. if (substr($this->getThemesDir(), 0, $basePathLength) === $basePath) {
  1542. $this->config['theme_url'] = $this->getBaseUrl() . substr($this->getThemesDir(), $basePathLength);
  1543. } else {
  1544. $this->config['theme_url'] = $this->getBaseUrl() . basename($this->getThemesDir()) . '/';
  1545. }
  1546. return $this->config['theme_url'];
  1547. }
  1548. /**
  1549. * Filters a URL GET parameter with a specified filter
  1550. *
  1551. * This method is just an alias for {@link Pico::filterVariable()}, see
  1552. * {@link Pico::filterVariable()} for a detailed description. It can be
  1553. * used in Twig templates by calling the `url_param` function.
  1554. *
  1555. * @see Pico::filterVariable()
  1556. * @param string $name name of the URL GET parameter
  1557. * to filter
  1558. * @param int|string $filter the filter to apply
  1559. * @param mixed|array $options either a associative options
  1560. * array to be used by the filter or a scalar default value
  1561. * @param int|string|int[]|string[] $flags flags and flag strings to
  1562. * be used by the filter
  1563. * @return mixed either the filtered data,
  1564. * FALSE if the filter fails, or NULL if the URL GET parameter doesn't
  1565. * exist and no default value is given
  1566. */
  1567. public function getUrlParameter($name, $filter = '', $options = null, $flags = null)
  1568. {
  1569. $variable = (isset($_GET[$name]) && is_scalar($_GET[$name])) ? $_GET[$name] : null;
  1570. return $this->filterVariable($variable, $filter, $options, $flags);
  1571. }
  1572. /**
  1573. * Filters a HTTP POST parameter with a specified filter
  1574. *
  1575. * This method is just an alias for {@link Pico::filterVariable()}, see
  1576. * {@link Pico::filterVariable()} for a detailed description. It can be
  1577. * used in Twig templates by calling the `form_param` function.
  1578. *
  1579. * @see Pico::filterVariable()
  1580. * @param string $name name of the HTTP POST
  1581. * parameter to filter
  1582. * @param int|string $filter the filter to apply
  1583. * @param mixed|array $options either a associative options
  1584. * array to be used by the filter or a scalar default value
  1585. * @param int|string|int[]|string[] $flags flags and flag strings to
  1586. * be used by the filter
  1587. * @return mixed either the filtered data,
  1588. * FALSE if the filter fails, or NULL if the HTTP POST parameter
  1589. * doesn't exist and no default value is given
  1590. */
  1591. public function getFormParameter($name, $filter = '', $options = null, $flags = null)
  1592. {
  1593. $variable = (isset($_POST[$name]) && is_scalar($_POST[$name])) ? $_POST[$name] : null;
  1594. return $this->filterVariable($variable, $filter, $options, $flags);
  1595. }
  1596. /**
  1597. * Filters a variable with a specified filter
  1598. *
  1599. * This method basically wraps around PHP's `filter_var()` function. It
  1600. * filters data by either validating or sanitizing it. This is especially
  1601. * useful when the data source contains unknown (or foreign) data, like
  1602. * user supplied input. Validation is used to validate or check if the data
  1603. * meets certain qualifications, but will not change the data itself.
  1604. * Sanitization will sanitize the data, so it may alter it by removing
  1605. * undesired characters. It doesn't actually validate the data! The
  1606. * behaviour of most filters can optionally be tweaked by flags.
  1607. *
  1608. * Heads up! Input validation is hard! Always validate your input data the
  1609. * most paranoid way you can imagine. Always prefer validation filters over
  1610. * sanitization filters; be very careful with sanitization filters, you
  1611. * might create cross-site scripting vulnerabilities!
  1612. *
  1613. * @see https://secure.php.net/manual/en/function.filter-var.php
  1614. * PHP's `filter_var()` function
  1615. * @see https://secure.php.net/manual/en/filter.filters.validate.php
  1616. * Validate filters
  1617. * @see https://secure.php.net/manual/en/filter.filters.sanitize.php
  1618. * Sanitize filters
  1619. * @param mixed $variable value to filter
  1620. * @param int|string $filter ID (int) or name (string) of
  1621. * the filter to apply; if omitted, the method will return FALSE
  1622. * @param mixed|array $options either a associative array
  1623. * of options to be used by the filter (e.g. `array('default' => 42)`),
  1624. * or a scalar default value that will be returned when the passed
  1625. * value is NULL (optional)
  1626. * @param int|string|int[]|string[] $flags either a bitwise disjunction
  1627. * of flags or a string with the significant part of a flag constant
  1628. * (the constant name is the result of "FILTER_FLAG_" and the given
  1629. * string in ASCII-only uppercase); you may also pass an array of flags
  1630. * and flag strings (optional)
  1631. * @return mixed with a validation filter,
  1632. * the method either returns the validated value or, provided that the
  1633. * value wasn't valid, the given default value or FALSE; with a
  1634. * sanitization filter, the method returns the sanitized value; if no
  1635. * value (i.e. NULL) was given, the method always returns either the
  1636. * provided default value or NULL
  1637. */
  1638. protected function filterVariable($variable, $filter = '', $options = null, $flags = null)
  1639. {
  1640. $defaultValue = null;
  1641. if (is_array($options)) {
  1642. $defaultValue = isset($options['default']) ? $options['default'] : null;
  1643. } elseif ($options !== null) {
  1644. $defaultValue = $options;
  1645. $options = array('default' => $defaultValue);
  1646. }
  1647. if ($variable === null) {
  1648. return $defaultValue;
  1649. }
  1650. $filter = $filter ? (is_string($filter) ? filter_id($filter) : (int) $filter) : false;
  1651. if (!$filter) {
  1652. return false;
  1653. }
  1654. $filterOptions = array('options' => $options, 'flags' => 0);
  1655. foreach ((array) $flags as $flag) {
  1656. if (is_numeric($flag)) {
  1657. $filterOptions['flags'] |= (int) $flag;
  1658. } elseif (is_string($flag)) {
  1659. $flag = strtoupper(preg_replace('/[^a-zA-Z0-9_]/', '', $flag));
  1660. if (($flag === 'NULL_ON_FAILURE') && ($filter === FILTER_VALIDATE_BOOLEAN)) {
  1661. $filterOptions['flags'] |= FILTER_NULL_ON_FAILURE;
  1662. } else {
  1663. $filterOptions['flags'] |= (int) constant('FILTER_FLAG_' . $flag);
  1664. }
  1665. }
  1666. }
  1667. return filter_var($variable, $filter, $filterOptions);
  1668. }
  1669. /**
  1670. * Recursively walks through a directory and returns all containing files
  1671. * matching the specified file extension
  1672. *
  1673. * @param string $directory start directory
  1674. * @param string $fileExtension return files with the given file extension
  1675. * only (optional)
  1676. * @param int $order specify whether and how files should be
  1677. * sorted; use Pico::SORT_ASC for a alphabetical ascending order (this
  1678. * is the default behaviour), Pico::SORT_DESC for a descending order
  1679. * or Pico::SORT_NONE to leave the result unsorted
  1680. * @return array list of found files
  1681. */
  1682. public function getFiles($directory, $fileExtension = '', $order = self::SORT_ASC)
  1683. {
  1684. $directory = rtrim($directory, '/');
  1685. $result = array();
  1686. // scandir() reads files in alphabetical order
  1687. $files = scandir($directory, $order);
  1688. $fileExtensionLength = strlen($fileExtension);
  1689. if ($files !== false) {
  1690. foreach ($files as $file) {
  1691. // exclude hidden files/dirs starting with a .; this also excludes the special dirs . and ..
  1692. // exclude files ending with a ~ (vim/nano backup) or # (emacs backup)
  1693. if (($file[0] === '.') || in_array(substr($file, -1), array('~', '#'))) {
  1694. continue;
  1695. }
  1696. if (is_dir($directory . '/' . $file)) {
  1697. // get files recursively
  1698. $result = array_merge($result, $this->getFiles($directory . '/' . $file, $fileExtension, $order));
  1699. } elseif (!$fileExtension || (substr($file, -$fileExtensionLength) === $fileExtension)) {
  1700. $result[] = $directory . '/' . $file;
  1701. }
  1702. }
  1703. }
  1704. return $result;
  1705. }
  1706. /**
  1707. * Returns all files in a directory matching a libc glob() pattern
  1708. *
  1709. * @see https://secure.php.net/manual/en/function.glob.php
  1710. * PHP's glob() function
  1711. * @param string $pattern the pattern to search for; see PHP's glob()
  1712. * function for details
  1713. * @param int $order specify whether and how files should be sorted;
  1714. * use Pico::SORT_ASC for a alphabetical ascending order (this is the
  1715. * default behaviour), Pico::SORT_DESC for a descending order or
  1716. * Pico::SORT_NONE to leave the result unsorted
  1717. * @return array list of found files
  1718. */
  1719. public function getFilesGlob($pattern, $order = self::SORT_ASC)
  1720. {
  1721. $result = array();
  1722. $sortFlag = ($order === self::SORT_NONE) ? GLOB_NOSORT : 0;
  1723. $files = glob($pattern, GLOB_MARK | $sortFlag);
  1724. if ($files) {
  1725. foreach ($files as $file) {
  1726. // exclude dirs and files ending with a ~ (vim/nano backup) or # (emacs backup)
  1727. if (in_array(substr($file, -1), array('/', '~', '#'))) {
  1728. continue;
  1729. }
  1730. $result[] = $file;
  1731. }
  1732. }
  1733. return ($order === self::SORT_DESC) ? array_reverse($result) : $result;
  1734. }
  1735. /**
  1736. * Makes a relative path absolute to Pico's root dir
  1737. *
  1738. * This method also guarantees a trailing slash.
  1739. *
  1740. * @param string $path relative or absolute path
  1741. * @return string absolute path
  1742. */
  1743. public function getAbsolutePath($path)
  1744. {
  1745. if (strncasecmp(PHP_OS, 'WIN', 3) === 0) {
  1746. if (preg_match('/^([a-zA-Z]:\\\\|\\\\\\\\)/', $path) !== 1) {
  1747. $path = $this->getRootDir() . $path;
  1748. }
  1749. } else {
  1750. if ($path[0] !== '/') {
  1751. $path = $this->getRootDir() . $path;
  1752. }
  1753. }
  1754. return rtrim($path, '/\\') . '/';
  1755. }
  1756. /**
  1757. * Triggers events on plugins which implement PicoPluginInterface
  1758. *
  1759. * Deprecated events (as used by plugins not implementing
  1760. * {@link PicoPluginInterface}) are triggered by {@link PicoDeprecated}.
  1761. * You MUST NOT trigger events of Pico's core with a plugin!
  1762. *
  1763. * @see PicoPluginInterface
  1764. * @see AbstractPicoPlugin
  1765. * @see DummyPlugin
  1766. * @param string $eventName name of the event to trigger
  1767. * @param array $params optional parameters to pass
  1768. * @return void
  1769. */
  1770. public function triggerEvent($eventName, array $params = array())
  1771. {
  1772. if ($this->plugins) {
  1773. foreach ($this->plugins as $plugin) {
  1774. // only trigger events for plugins that implement PicoPluginInterface
  1775. // deprecated events (plugins for Pico 0.9 and older) will be triggered by `PicoDeprecated`
  1776. if ($plugin instanceof PicoPluginInterface) {
  1777. $plugin->handleEvent($eventName, $params);
  1778. }
  1779. }
  1780. }
  1781. }
  1782. }