diff --git a/lib/Pico.php b/lib/Pico.php index 3e9b43f..8149876 100644 --- a/lib/Pico.php +++ b/lib/Pico.php @@ -1,170 +1,565 @@ getPlugin('PicoParsePagesContent')->setEnabled(false);` +# or adding `$config['PicoParsePagesContent.enabled'] = false;` to your +# `config.php`. +# - The meta headers are now parsed by the YAML component of the Symfony +# project (see #116), but still requires you to register new headers during +# the `onMetaHeaders` event. I'm uncertain about still requiring that. What +# do you think? +# - Meta header variables are now accessible in content files using `%meta.*%`, +# so you mustn't repeat yourself. You can now put an excerpt into the +# `description` meta variable and output the same content at the beginning +# of the article using `%meta.description%`. +# - I decided explicitly to NOT implement pages as objects ("stupidly simple", +# see above). Anyway, I think plugin developers shouldn't manipulate data in +# "wrong" events, this could lead us to unexpected behaviour. Sure, plugin +# developers still can do this, we're passing variables by reference, but +# it's not that obvious. I even thought about dereferencing the values after +# the corrosponding event was called, but that would be backward incompatible. +# What do you think? +# - How to fix the "composer problem" discussed in #221 and #223? There's a +# very simple solution: When creating a release on GitHub (after you've +# pushed the tag) you can upload "binaries". Simply execute composer locally, +# create a ZIP archive and upload the result as "binary". +# - I didn't care much about #110, #238, #239 and #240 because I simply don't +# need these features. But I think they are good ideas and the core should +# support this. Just my 2 cents :smile: +# - #201 and #231 should be closed - this can easily be achieved with plugins. +# In fact, there are already plugins adding support for these features... +# - Imo distinct documentations for users, theme designers and plugin devs is +# MUCH more important than unit tests... Pico is a project with just about +# 500 LoC (+ comments), such a manageable project doesn't necessarily require +# unit tests - they are nice to have, but that's it. Documentation should be +# the top priority! +# - Note: I'm no english native speaker. Maybe someone should look through my +# code comments :smile: +# + /** * Pico * - * @author Gilbert Pellegrom - * @link http://picocms.org - * @license http://opensource.org/licenses/MIT - * @version 0.8 + * Pico is a stupidly simple, blazing fast, flat file CMS. + * - Stupidly Simple: Picos makes creating and maintaining a + * website as simple as editing text files. + * - Blazing Fast: Pico is seriously lightweight and doesn't + * use a database, making it super fast. + * - No Database: Pico is a "flat file" CMS, meaning no + * database woes, no MySQL queries, nothing. + * - Markdown Formatting: Edit your website in your favourite + * text editor using simple Markdown formatting. + * - Twig Templates: Pico uses the Twig templating engine, + * for powerful and flexible themes. + * - Open Source: Pico is completely free and open source, + * released under the MIT license. + * See for more info. + * + * @author Gilbert Pellegrom + * @author Daniel Rudolf + * @link + * @license The MIT License + * @version 1.0 */ class Pico { - - private $config; - private $plugins; + /** + * List of loaded plugins + * + * @see Pico::loadPlugins() + * @var array + */ + protected $plugins; /** + * Current configuration of this Pico instance + * + * @see Pico::loadConfig() + * @var array + */ + protected $config; + + /** + * URL with which the user requested the page + * + * @see Pico::evaluateRequestUrl() + * @var string + */ + protected $requestUrl; + + /** + * Path to the content file being served + * + * @see Pico::discoverRequestFile() + * @var string + */ + protected $requestFile; + + /** + * Raw, not yet parsed contents to serve + * + * @see Pico::loadFileContent() + * @var string + */ + protected $rawContent; + + /** + * Meta data of the page to serve + * + * @see Pico::parseFileMeta() + * @var array + */ + protected $meta; + + /** + * Parsed content being served + * + * @see Pico::prepareFileContent() + * @see Pico::parseFileContent() + * @var string + */ + protected $content; + + /** + * List of known pages + * + * @see Pico::readPages() + * @var array + */ + protected $pages; + + /** + * Data of the page being served + * + * @see Pico::discoverCurrentPage() + * @var array + */ + protected $currentPage; + + /** + * Data of the previous page relative to the page being served + * + * @see Pico::discoverCurrentPage() + * @var array + */ + protected $previousPage; + + /** + * Data of the next page relative to the page being served + * + * @see Pico::discoverCurrentPage() + * @var array + */ + protected $nextPage; + + /** + * Twig instance used for template parsing + * + * @see Pico::registerTwig() + * @var Twig_Environment + */ + protected $twig; + + /** + * Variables passed to the twig template + * + * @var array + */ + protected $twigVariables; + + /** + * Constructs a new Pico instance + * * The constructor carries out all the processing in Pico. * Does URL routing, Markdown processing and Twig processing. */ public function __construct() { - // Load plugins - $this->load_plugins(); - $this->run_hooks('plugins_loaded'); + // load plugins + $this->loadPlugins(); + $this->triggerEvent('onPluginsLoaded', array(&$this->plugins)); - // Load the settings - $settings = $this->get_config(); - $this->run_hooks('config_loaded', array(&$settings)); + // load config + $this->loadConfig(); + $this->triggerEvent('onConfigLoaded', array(&$this->config)); - // Get request url and script url - $url = ''; - $request_url = (isset($_SERVER['REQUEST_URI'])) ? $_SERVER['REQUEST_URI'] : ''; - $script_url = (isset($_SERVER['PHP_SELF'])) ? $_SERVER['PHP_SELF'] : ''; + // evaluate request url + $this->evaluateRequestUrl(); + $this->triggerEvent('onRequestUrl', array(&$this->requestUrl)); - // Get our url path and trim the / of the left and the right - if ($request_url != $script_url) { - $url = trim(preg_replace('/' . str_replace('/', '\/', str_replace('index.php', '', $script_url)) . '/', '', - $request_url, 1), '/'); - } - $url = preg_replace('/\?.*/', '', $url); // Strip query string - $this->run_hooks('request_url', array(&$url)); + // discover requested file + $this->discoverRequestFile(); + $this->triggerEvent('onRequestFile', array(&$this->requestFile)); - // Get the file path - if ($url) { - $file = $settings['content_dir'] . $url; + // load raw file content + $this->triggerEvent('onContentLoading', array(&$this->requestFile)); + + if (file_exists($this->requestFile)) { + $this->rawContent = $this->loadFileContent($this->requestFile); } else { - $file = $settings['content_dir'] . 'index'; - } + $this->triggerEvent('on404ContentLoading', array(&$this->requestFile)); - // Load the file - if (is_dir($file)) { - $file = $settings['content_dir'] . $url . '/index' . CONTENT_EXT; - } else { - $file .= CONTENT_EXT; - } - - $this->run_hooks('before_load_content', array(&$file)); - if (file_exists($file)) { - $content = file_get_contents($file); - } else { - $this->run_hooks('before_404_load_content', array(&$file)); - $content = file_get_contents($settings['content_dir'] . '404' . CONTENT_EXT); header($_SERVER['SERVER_PROTOCOL'] . ' 404 Not Found'); - $this->run_hooks('after_404_load_content', array(&$file, &$content)); + $this->rawContent = $this->load404Content(); + + $this->triggerEvent('on404ContentLoaded', array(&$this->rawContent)); } - $this->run_hooks('after_load_content', array(&$file, &$content)); - $meta = $this->read_file_meta($content); - $this->run_hooks('file_meta', array(&$meta)); + $this->triggerEvent('onContentLoaded', array(&$this->rawContent)); - $this->run_hooks('before_parse_content', array(&$content)); - $content = $this->parse_content($content); - $this->run_hooks('after_parse_content', array(&$content)); - $this->run_hooks('content_parsed', array(&$content)); // Depreciated @ v0.8 + // parse file meta + $headers = $this->getMetaHeaders(); - // Get all the pages - $pages = $this->get_pages($settings['base_url'], $settings['pages_order_by'], $settings['pages_order'], - $settings['excerpt_length']); - $prev_page = array(); - $current_page = array(); - $next_page = array(); - while ($current_page = current($pages)) { - if ((isset($meta['title'])) && ($meta['title'] == $current_page['title'])) { - break; - } - next($pages); + $this->triggerEvent('onMetaParsing', array(&$this->rawContent, &$headers)); + $this->meta = $this->parseFileMeta($this->rawContent, $headers); + $this->triggerEvent('onMetaParsed', array(&$this->meta)); + + // parse file content + $this->triggerEvent('onContentParsing', array(&$this->rawContent)); + + $this->content = $this->prepareFileContent($this->rawContent); + $this->triggerEvent('onContentPrepared', array(&$this->content)); + + $this->content = $this->parseFileContent($this->content); + $this->triggerEvent('onContentParsed', array(&$this->content)); + + // read pages + $this->triggerEvent('onPagesLoading'); + + $this->readPages(); + $this->discoverCurrentPage(); + + $this->triggerEvent('onPagesLoaded', array( + &$this->pages, + &$this->currentPage, + &$this->previousPage, + &$this->nextPage + )); + + // register twig + $this->triggerEvent('onTwigRegistration'); + $this->registerTwig(); + + // render template + $this->twigVariables = $this->getTwigVariables(); + if (isset($this->meta['template']) && $this->meta['template']) { + $templateName = $this->meta['template']; + } else { + $templateName = 'index'; + } + if (file_exists(THEMES_DIR . $this->getConfig('theme') . '/' . $templateName . '.twig')) { + $templateName .= '.twig'; + } else { + $templateName .= '.html'; } - $prev_page = next($pages); - prev($pages); - $next_page = prev($pages); - $this->run_hooks('get_pages', array(&$pages, &$current_page, &$prev_page, &$next_page)); - // Load the theme - $this->run_hooks('before_twig_register'); - Twig_Autoloader::register(); - $loader = new Twig_Loader_Filesystem(THEMES_DIR . $settings['theme']); - $twig = new Twig_Environment($loader, $settings['twig_config']); - $twig->addExtension(new Twig_Extension_Debug()); - $twig_vars = array( - 'config' => $settings, - 'base_dir' => rtrim(ROOT_DIR, '/'), - 'base_url' => $settings['base_url'], - 'theme_dir' => THEMES_DIR . $settings['theme'], - 'theme_url' => $settings['base_url'] . '/' . basename(THEMES_DIR) . '/' . $settings['theme'], - 'site_title' => $settings['site_title'], - 'meta' => $meta, - 'content' => $content, - 'pages' => $pages, - 'prev_page' => $prev_page, - 'current_page' => $current_page, - 'next_page' => $next_page, - 'is_front_page' => $url ? false : true, - ); + $this->triggerEvent('onPageRendering', array(&$this->twig, &$this->twigVariables, &$templateName)); + + $output = $this->twig->render($templateName, $this->twigVariables); + $this->triggerEvent('onPageRendered', array(&$output)); - $template = (isset($meta['template']) && $meta['template']) ? $meta['template'] : 'index'; - $this->run_hooks('before_render', array(&$twig_vars, &$twig, &$template)); - $output = $twig->render($template . '.html', $twig_vars); - $this->run_hooks('after_render', array(&$output)); echo $output; } /** - * Load any plugins + * Loads plugins from PLUGINS_DIR in alphabetical order + * + * Plugin files may be prefixed by a number (e.g. 00-PicoDeprecated.php) + * to indicate their processsing order. You MUST NOT use prefixes between + * 00 and 19 (reserved for built-in plugins). + * + * @return void + * @throws RuntimeException thrown when a plugin couldn't be loaded */ - protected function load_plugins() + protected function loadPlugins() { $this->plugins = array(); - $plugins = $this->get_files(PLUGINS_DIR, '.php'); - if (!empty($plugins)) { - foreach ($plugins as $plugin) { - include_once($plugin); - $plugin_name = preg_replace("/\\.[^.\\s]{3}$/", '', basename($plugin)); - if (class_exists($plugin_name)) { - $obj = new $plugin_name; - $this->plugins[] = $obj; - } + $pluginFiles = $this->getFiles(PLUGINS_DIR, '.php'); + foreach ($pluginFiles as $pluginFile) { + require_once($pluginFile); + + $className = preg_replace('/^[0-9]+-/', '', basename($pluginFile, '.php')); + if (class_exists($className)) { + $this->plugins[$className] = new $className($this); + } else { + // TODO: breaks backward compatibility + //throw new RuntimeException("Unable to load plugin '".$className."'"); } } } /** - * Parses the content using Parsedown-extra + * Returns the instance of a named plugin * - * @param string $content the raw txt content - * @return string $content the Markdown formatted content + * Plugins SHOULD implement {@link IPicoPlugin}, but you MUST NOT rely on + * it. For more information see {@link IPicoPlugin}. + * + * @see Pico::loadPlugins() + * @param string $pluginName name of the plugin + * @return object instance of the plugin + * @throws RuntimeException thrown when the plugin wasn't found */ - protected function parse_content($content) + public function getPlugin($pluginName) { - $content = preg_replace('#/\*.+?\*/#s', '', $content, 1); // Remove first comment (with meta) - $content = str_replace('%base_url%', $this->base_url(), $content); - $Parsedown = new ParsedownExtra(); - $content= $Parsedown->text($content); + if (isset($this->plugins[$pluginName])) { + return $this->plugins[$pluginName]; + } - return $content; + throw new RuntimeException("Missing plugin '".$pluginName."'"); } /** - * Parses the file meta from the txt file header + * Returns all loaded plugins * - * @param string $content the raw txt content - * @return array $headers an array of meta values + * @see Pico::loadPlugins() + * @return array */ - protected function read_file_meta($content) + public function getPlugins() { - $config = $this->config; + return $this->plugins; + } + /** + * Loads the config.php from CONFIG_DIR + * + * @return void + */ + protected function loadConfig() + { + $defaultConfig = array( + 'site_title' => 'Pico', + 'base_url' => '', + 'rewrite_url' => null, + 'theme' => 'default', + 'date_format' => '%D %T', + 'twig_config' => array('cache' => false, 'autoescape' => false, 'debug' => false), + 'pages_order_by' => 'alpha', + 'pages_order' => 'asc', + 'content_dir' => ROOT_DIR . 'content-sample/', + 'content_ext' => '.md', + 'timezone' => '' + ); + + $config = require(CONFIG_DIR . 'config.php'); + $this->config = is_array($config) ? $config + $defaultConfig : $defaultConfig; + + if (empty($this->config['base_url'])) { + $this->config['base_url'] = $this->getBaseUrl(); + } + if (!empty($this->config['content_dir'])) { + $this->config['content_dir'] = rtrim($this->config['content_dir'], '/') . '/'; + } + if (!empty($this->config['timezone'])) { + date_default_timezone_set($this->config['timezone']); + } else { + // explicitly set a default timezone to prevent a E_NOTICE + // when no timezone is set; the `date_default_timezone_get()` + // function always returns a timezone, at least UTC + $defaultTimezone = date_default_timezone_get(); + date_default_timezone_set($defaultTimezone); + } + } + + /** + * Returns either the value of the specified config variable or + * the config array + * + * @see Pico::loadConfig() + * @param string $configName optional name of a config variable + * @return mixed returns either the value of the named config + * variable, null if the config variable doesn't exist or the config + * array if no config name was supplied + */ + public function getConfig($configName = null) + { + if ($configName !== null) { + return isset($this->config[$configName]) ? $this->config[$configName] : null; + } else { + return $this->config; + } + } + + /** + * Evaluates the requested URL + * + * Pico 1.0 uses the QUERY_STRING routing method (e.g. /pico/?sub/page) to + * support SEO-like URLs out-of-the-box with any webserver. You can still + * setup URL rewriting (e.g. using mod_rewrite on Apache) to basically + * remove the `?` from URLs, but your rewritten URLs must follow the + * new QUERY_STRING principles. URL rewriting requires some special + * configuration on your webserver, but this should be "basic work" for + * any webmaster... + * + * Pico 0.9 and older required Apache with mod_rewrite enabled, thus old + * plugins, templates and contents may require you to enable URL rewriting + * to work. If you're upgrading from Pico 0.9, you probably have to update + * your rewriting rules. + * + * We recommend you to use the `link` filter in templates to create + * internal links, e.g. `{{ "sub/page"|link }}` is equivalent to + * `{{ base_url }}sub/page`. In content files you can still use the + * `%base_url%` variable; e.g. `%base_url%?sub/page` is automatically + * replaced accordingly. + * + * @return void + */ + protected function evaluateRequestUrl() + { + // use QUERY_STRING; e.g. /pico/?sub/page + // if you want to use rewriting, you MUST make your rules to + // rewrite the URLs to follow the QUERY_STRING method + // + // Note: you MUST NOT call the index page with /pico/?someBooleanParameter; + // use /pico/?someBooleanParameter= or /pico/?index&someBooleanParameter instead + $pathComponent = $_SERVER['QUERY_STRING']; + if (($pathComponentLength = strpos($pathComponent, '&')) !== false) { + $pathComponent = substr($pathComponent, 0, $pathComponentLength); + } + $this->requestUrl = (strpos($pathComponent, '=') === false) ? urldecode($pathComponent) : ''; + } + + /** + * Returns the URL with which the user requested the page + * + * @see Pico::evaluateRequestUrl() + * @return string request URL + */ + public function getRequestUrl() + { + return $this->requestUrl; + } + + /** + * Uses the request URL to discover the content file to serve + * + * @return void + */ + protected function discoverRequestFile() + { + if (empty($this->requestUrl)) { + $this->requestFile = $this->getConfig('content_dir') . 'index' . $this->getConfig('content_ext'); + } else { + $this->requestFile = $this->getConfig('content_dir') . $this->requestUrl; + if (is_dir($this->requestFile)) { + // if no index file is found, try a accordingly named file in the previous dir + // if this file doesn't exist either, show the 404 page, but assume the index + // file as being requested (maintains backward compatibility to Pico < 1.0) + $indexFile = $this->requestFile . '/index' . $this->getConfig('content_ext'); + if (file_exists($indexFile) || !file_exists($this->requestFile . $this->getConfig('content_ext'))) { + $this->requestFile = $indexFile; + return; + } + } + $this->requestFile .= $this->getConfig('content_ext'); + } + } + + /** + * Returns the path to the content file to serve + * + * @see Pico::discoverRequestFile() + * @return string file path + */ + public function getRequestFile() + { + return $this->requestFile; + } + + /** + * Returns the raw contents of a file + * + * @param string $file file path + * @return string raw contents of the file + */ + public function loadFileContent($file) + { + return file_get_contents($file); + } + + /** + * Returns the raw contents of the 404 file if the requested file wasn't found + * + * @return string raw contents of the 404 file + */ + public function load404Content() + { + return $this->loadFileContent($this->getConfig('content_dir') . '404' . $this->getConfig('content_ext')); + } + + /** + * Returns the cached raw contents, either of the requested or the 404 file + * + * @see Pico::loadFileContent() + * @return string raw contents + */ + public function getRawContent() + { + return $this->rawContent; + } + + /** + * Returns known meta headers and triggers the onMetaHeaders event + * + * Heads up! Calling this method triggers the `onMetaHeaders` event. + * Keep this in mind to prevent a infinite loop! + * + * @return array known meta headers + */ + public function getMetaHeaders() + { $headers = array( 'title' => 'Title', 'description' => 'Description', @@ -174,232 +569,461 @@ class Pico 'template' => 'Template' ); - // Add support for custom headers by hooking into the headers array - $this->run_hooks('before_read_file_meta', array(&$headers)); - - foreach ($headers as $field => $regex) { - if (preg_match('/^[ \t\/*#@]*' . preg_quote($regex, '/') . ':(.*)$/mi', $content, $match) && $match[1]) { - $headers[$field] = trim(preg_replace("/\s*(?:\*\/|\?>).*/", '', $match[1])); - } else { - $headers[$field] = ''; - } - } - - if (isset($headers['date'])) { - $headers['date_formatted'] = utf8_encode(strftime($config['date_format'], strtotime($headers['date']))); - } - + $this->triggerEvent('onMetaHeaders', array(&$headers)); return $headers; } /** - * Loads the config + * Parses the file meta from raw file contents * - * @return array $config an array of config values - */ - protected function get_config() - { - if (file_exists(CONFIG_DIR . 'config.php')) { - $this->config = require(CONFIG_DIR . 'config.php'); - } else if (file_exists(ROOT_DIR . 'config.php')) { - // deprecated - $this->config = require(ROOT_DIR . 'config.php'); - } - - $defaults = array( - 'site_title' => 'Pico', - 'base_url' => $this->base_url(), - 'theme' => 'default', - 'date_format' => '%D %T', - 'twig_config' => array('cache' => false, 'autoescape' => false, 'debug' => false), - 'pages_order_by' => 'alpha', - 'pages_order' => 'asc', - 'excerpt_length' => 50, - 'content_dir' => 'content-sample/', - ); - - if (is_array($this->config)) { - $this->config = array_merge($defaults, $this->config); - } else { - $this->config = $defaults; - } - - return $this->config; - } - - /** - * Get a list of pages + * Meta data MUST start on the first line of the file, either opened and + * closed by --- or C-style block comments (deprecated). The headers are + * parsed by the YAML component of the Symfony project. You MUST register + * new headers during the `onMetaHeaders` event first, otherwise they are + * ignored and won't be returned. * - * @param string $base_url the base URL of the site - * @param string $order_by order by "alpha" or "date" - * @param string $order order "asc" or "desc" - * @return array $sorted_pages an array of pages + * @see + * @param string $content the raw file contents + * @param array $headers a array containing the known headers + * @return array parsed meta data */ - protected function get_pages($base_url, $order_by = 'alpha', $order = 'asc', $excerpt_length = 50) + public function parseFileMeta($rawContent, array $headers) { - $config = $this->config; + $meta = array(); + $pattern = "/^(\/(\*)|---)[[:blank:]]*(?:\r)?\n" + . "(.*?)(?:\r)?\n(?(2)\*\/|---)[[:blank:]]*(?:(?:\r)?\n|$)/s"; + if (preg_match($pattern, $rawContent, $rawMetaMatches)) { + $yamlParser = new \Symfony\Component\Yaml\Parser(); + $rawMeta = $yamlParser->parse($rawMetaMatches[3]); + $rawMeta = array_change_key_case($rawMeta, CASE_LOWER); - $pages = $this->get_files($config['content_dir'], CONTENT_EXT); - $sorted_pages = array(); - $date_id = 0; - foreach ($pages as $key => $page) { - // Skip 404 - if (basename($page) == '404' . CONTENT_EXT) { - unset($pages[$key]); - continue; - } - - // Ignore Emacs (and Nano) temp files - if (in_array(substr($page, -1), array('~', '#'))) { - unset($pages[$key]); - continue; - } - // Get title and format $page - $page_content = file_get_contents($page); - $page_meta = $this->read_file_meta($page_content); - $page_content = $this->parse_content($page_content); - $url = str_replace($config['content_dir'], $base_url . '/', $page); - $url = str_replace('index' . CONTENT_EXT, '', $url); - $url = str_replace(CONTENT_EXT, '', $url); - $data = array( - 'title' => isset($page_meta['title']) ? $page_meta['title'] : '', - 'url' => $url, - 'author' => isset($page_meta['author']) ? $page_meta['author'] : '', - 'date' => isset($page_meta['date']) ? $page_meta['date'] : '', - 'date_formatted' => isset($page_meta['date']) ? utf8_encode(strftime($config['date_format'], - strtotime($page_meta['date']))) : '', - 'content' => $page_content, - 'excerpt' => $this->limit_words(strip_tags($page_content), $excerpt_length), - //this addition allows the 'description' meta to be picked up in content areas... specifically to replace 'excerpt' - 'description' => isset($page_meta['description']) ? $page_meta['description'] : '', - - ); - - // Extend the data provided with each page by hooking into the data array - $this->run_hooks('get_page_data', array(&$data, $page_meta)); - - if ($order_by == 'date' && isset($page_meta['date'])) { - $sorted_pages[$page_meta['date'] . $date_id] = $data; - $date_id++; - } else { - $sorted_pages[$page] = $data; - } - } - - if ($order == 'desc') { - krsort($sorted_pages); - } else { - ksort($sorted_pages); - } - - return $sorted_pages; - } - - /** - * Processes any hooks and runs them - * - * @param string $hook_id the ID of the hook - * @param array $args optional arguments - */ - protected function run_hooks($hook_id, $args = array()) - { - if (!empty($this->plugins)) { - foreach ($this->plugins as $plugin) { - if (is_callable(array($plugin, $hook_id))) { - call_user_func_array(array($plugin, $hook_id), $args); + // TODO: maybe we should change this to pass all headers, no matter + // they are registered during the `onMetaHeaders` event or not... + foreach ($headers as $fieldId => $fieldName) { + $fieldName = strtolower($fieldName); + if (isset($rawMeta[$fieldName])) { + $meta[$fieldId] = $rawMeta[$fieldName]; + } else { + $meta[$fieldId] = ''; } } + + if (!empty($meta['date'])) { + $meta['time'] = strtotime($meta['date']); + $meta['date_formatted'] = utf8_encode(strftime($this->getConfig('date_format'), $meta['time'])); + } else { + $meta['time'] = $meta['date_formatted'] = ''; + } + } else { + foreach ($headers as $id => $field) { + $meta[$id] = ''; + } + + $meta['time'] = $meta['date_formatted'] = ''; + } + + return $meta; + } + + /** + * Returns the parsed meta data of the requested page + * + * @see Pico::parseFileMeta() + * @return array parsed meta data + */ + public function getFileMeta() + { + return $this->meta; + } + + /** + * Applies some static preparations to the raw contents of a page, + * e.g. removing the meta header and replacing %base_url% + * + * @param string $rawContent raw contents of a page + * @return string contents prepared for parsing + */ + public function prepareFileContent($rawContent) + { + // remove meta header + $metaHeaderPattern = "/^(\/(\*)|---)[[:blank:]]*(?:\r)?\n" + . "(.*?)(?:\r)?\n(?(2)\*\/|---)[[:blank:]]*(?:(?:\r)?\n|$)/s"; + $content = preg_replace($metaHeaderPattern, '', $rawContent, 1); + + // replace %site_title% + $content = str_replace('%site_title%', $this->getConfig('site_title'), $content); + + // replace %base_url% + if ($this->isUrlRewritingEnabled()) { + // always use `%base_url%?sub/page` syntax for internal links + // we'll replace the links accordingly, depending on enabled rewriting + $content = str_replace('%base_url%?', $this->getBaseUrl(), $content); + } else { + // actually not necessary, but makes the URLs look a little nicer + $content = str_replace('%base_url%?', $this->getBaseUrl() . '?', $content); + } + $content = str_replace('%base_url%', rtrim($this->getBaseUrl(), '/'), $content); + + // replace %theme_url% + $themeUrl = $this->getBaseUrl() . basename(THEMES_DIR) . '/' . $this->getConfig('theme'); + $content = str_replace('%theme_url%', $themeUrl, $content); + + // replace %meta.*% + $metaKeys = array_map(function ($metaKey) { + return '%meta.' . $metaKey . '%'; + }, array_keys($this->meta)); + $metaValues = array_values($this->meta); + $content = str_replace($metaKeys, $metaValues, $content); + + return $content; + } + + /** + * Parses the contents of a page using ParsedownExtra + * + * @param string $content raw contents of a page (Markdown) + * @return string parsed contents (HTML) + */ + public function parseFileContent($content) + { + $parsedown = new ParsedownExtra(); + return $parsedown->text($content); + } + + /** + * Returns the cached contents of the requested page + * + * @see Pico::parseFileContent() + * @return string parsed contents + */ + public function getFileContent() + { + return $this->content; + } + + /** + * Reads the data of all pages known to Pico + * + * @return void + */ + protected function readPages() + { + $pages = array(); + $files = $this->getFiles($this->getConfig('content_dir'), $this->getConfig('content_ext')); + foreach ($files as $i => $file) { + // skip 404 page + if (basename($file) == '404' . $this->getConfig('content_ext')) { + unset($files[$i]); + continue; + } + + $id = substr($file, strlen($this->getConfig('content_dir')), -strlen($this->getConfig('content_ext'))); + $url = $this->getPageUrl($id); + if ($file != $this->requestFile) { + $rawContent = file_get_contents($file); + $meta = $this->parseFileMeta($rawContent, $this->getMetaHeaders()); + } else { + $rawContent = &$this->rawContent; + $meta = &$this->meta; + } + + // build page data + // title, description, author and date are assumed to be pretty basic data + // everything else is accessible through $page['meta'] + $page = array( + 'id' => $id, + 'url' => $url, + 'title' => &$meta['title'], + 'description' => &$meta['description'], + 'author' => &$meta['author'], + 'time' => &$meta['time'], + 'date' => &$meta['date'], + 'date_formatted' => &$meta['date_formatted'], + 'raw_content' => &$rawContent, + 'meta' => &$meta + ); + + if ($file == $this->requestFile) { + $page['content'] = &$this->content; + } + + unset($rawContent, $meta); + + // trigger event + $this->triggerEvent('onSinglePageLoaded', array(&$page)); + + $pages[$id] = $page; + } + + // sort pages by date + // Pico::getFiles() already sorts alphabetically + $this->pages = $pages; + if ($this->getConfig('pages_order_by') == 'date') { + $pageIds = array_keys($this->pages); + $order = $this->getConfig('pages_order'); + + uasort($this->pages, function ($a, $b) use ($pageIds, $order) { + if (empty($a['time']) || empty($b['time'])) { + $cmp = (empty($a['time']) - empty($b['time'])); + } else { + $cmp = ($b['time'] - $a['time']); + } + + if ($cmp === 0) { + // never assume equality; fallback to the original order (= alphabetical) + $cmp = (array_search($b['id'], $pageIds) - array_search($a['id'], $pageIds)); + } + + return $cmp * (($order == 'desc') ? 1 : -1); + }); + } elseif ($this->getConfig('pages_order') == 'desc') { + $this->pages = array_reverse($this->pages); } } /** - * Helper function to work out the base URL + * Returns the list of known pages + * + * @see Pico::readPages() + * @return array the data of all pages + */ + public function getPages() + { + return $this->pages; + } + + /** + * Walks through the list of known pages and discovers the requested page + * as well as the previous and next page relative to it + * + * @return void + */ + protected function discoverCurrentPage() + { + $pageIds = array_keys($this->pages); + + $contentDir = $this->getConfig('content_dir'); + $contentExt = $this->getConfig('content_ext'); + $currentPageId = substr($this->requestFile, strlen($contentDir), -strlen($contentExt)); + $currentPageIndex = array_search($currentPageId, $pageIds); + if ($currentPageIndex !== false) { + $this->currentPage = &$this->pages[$currentPageId]; + + if (($this->getConfig('order_by') == 'date') && ($this->getConfig('order') == 'desc')) { + $previousPageOffset = 1; + $nextPageOffset = -1; + } else { + $previousPageOffset = -1; + $nextPageOffset = 1; + } + + if (isset($pageIds[$currentPageIndex + $previousPageOffset])) { + $previousPageId = $pageIds[$currentPageIndex + $previousPageOffset]; + $this->previousPage = &$this->pages[$previousPageId]; + } + + if (isset($pageIds[$currentPageIndex + $nextPageOffset])) { + $nextPageId = $pageIds[$currentPageIndex + $nextPageOffset]; + $this->nextPage = &$this->pages[$nextPageId]; + } + } + } + + /** + * Returns the data of the requested page + * + * @see Pico::discoverCurrentPage() + * @return array page data + */ + public function getCurrentPage() + { + return $this->currentPage; + } + + /** + * Returns the data of the previous page relative to the page being served + * + * @see Pico::discoverCurrentPage() + * @return array page data + */ + public function getPreviousPage() + { + return $this->previousPage; + } + + /** + * Returns the data of the next page relative to the page being served + * + * @see Pico::discoverCurrentPage() + * @return array page data + */ + public function getNextPage() + { + return $this->nextPage; + } + + /** + * Registers the twig template engine + * + * @return void + */ + protected function registerTwig() + { + $twigLoader = new Twig_Loader_Filesystem(THEMES_DIR . $this->getConfig('theme')); + $this->twig = new Twig_Environment($twigLoader, $this->getConfig('twig_config')); + $this->twig->addExtension(new Twig_Extension_Debug()); + $this->twig->addFilter(new Twig_SimpleFilter('link', array($this, 'getPageUrl'))); + } + + /** + * Returns the twig template engine + * + * @return Twig_Environment twig template engine + */ + public function getTwig() + { + return $this->twig; + } + + /** + * Returns the variables passed to the template + * + * URLs and paths (namely base_dir, base_url, theme_dir and theme_url) + * don't add a trailing slash for historic reasons. + * + * @return array template variables + */ + protected function getTwigVariables() + { + $frontPage = $this->getConfig('content_dir') . 'index' . $this->getConfig('content_ext'); + return array( + 'config' => $this->getConfig(), + 'base_dir' => rtrim(ROOT_DIR, '/'), + 'base_url' => rtrim($this->getBaseUrl(), '/'), + 'theme_dir' => THEMES_DIR . $this->getConfig('theme'), + 'theme_url' => $this->getBaseUrl() . basename(THEMES_DIR) . '/' . $this->getConfig('theme'), + 'rewrite_url' => $this->isUrlRewritingEnabled(), + 'site_title' => $this->getConfig('site_title'), + 'meta' => $this->meta, + 'content' => $this->content, + 'pages' => $this->pages, + 'prev_page' => $this->previousPage, + 'current_page' => $this->currentPage, + 'next_page' => $this->nextPage, + 'is_front_page' => ($this->requestFile == $frontPage), + ); + } + + /** + * Returns the base URL of this Pico instance * * @return string the base url */ - protected function base_url() + public function getBaseUrl() { - $config = $this->config; - - if (isset($config['base_url']) && $config['base_url']) { - return $config['base_url']; + if (!empty($this->getConfig('base_url'))) { + return $this->getConfig('base_url'); } - $url = ''; - $request_url = (isset($_SERVER['REQUEST_URI'])) ? $_SERVER['REQUEST_URI'] : ''; - $script_url = (isset($_SERVER['PHP_SELF'])) ? $_SERVER['PHP_SELF'] : ''; - if ($request_url != $script_url) { - $url = trim(preg_replace('/' . str_replace('/', '\/', str_replace('index.php', '', $script_url)) . '/', '', - $request_url, 1), '/'); - } - - $protocol = $this->get_protocol(); - - return rtrim(str_replace($url, '', $protocol . "://" . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']), '/'); - } - - /** - * Tries to guess the server protocol. Used in base_url() - * - * @return string the current protocol - */ - protected function get_protocol() - { - $protocol = 'http'; - if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off' && $_SERVER['HTTPS'] != '') { + if ( + (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off') + || ($_SERVER['SERVER_PORT'] == 443) + || (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https') + ) { $protocol = 'https'; + } else { + $protocol = 'http'; } - return $protocol; + $this->config['base_url'] = + $protocol . "://" . $_SERVER['HTTP_HOST'] + . dirname($_SERVER['SCRIPT_NAME']) . '/'; + + return $this->getConfig('base_url'); } /** - * Helper function to recusively get all files in a directory + * Returns true if URL rewriting is enabled * - * @param string $directory start directory - * @param string $ext optional limit to file extensions - * @return array the matched files + * @return boolean true if URL rewriting is enabled, false otherwise */ - protected function get_files($directory, $ext = '') + public function isUrlRewritingEnabled() { - $array_items = array(); - if ($files = scandir($directory)) { + if (($this->getConfig('rewrite_url') === null) && isset($_SERVER['PICO_URL_REWRITING'])) { + return (bool) $_SERVER['PICO_URL_REWRITING']; + } elseif ($this->getConfig('rewrite_url')) { + return true; + } + + return false; + } + + /** + * Returns the URL to a given page + * + * @param string $page identifier of the page to link to + * @return string URL + */ + public function getPageUrl($page) + { + return $this->getBaseUrl() . ((!$this->isUrlRewritingEnabled() && !empty($page)) ? '?' : '') . $page; + } + + /** + * Recursively walks through a directory and returns all containing files + * matching the specified file extension in alphabetical order + * + * @param string $directory start directory + * @param string $ext return files with this file extension only (optional) + * @return array list of found files + */ + protected function getFiles($directory, $fileExtension = '') + { + $directory = rtrim($directory, '/'); + $result = array(); + + // scandir() reads files in alphabetical order + $files = scandir($directory); + $fileExtensionLength = strlen($fileExtension); + if ($files !== false) { foreach ($files as $file) { - if (in_array(substr($file, -1), array('~', '#'))) { + // exclude hidden files/dirs starting with a .; this also excludes the special dirs . and .. + // exclude files ending with a ~ (vim/nano backup) or # (emacs backup) + if ((substr($file, 0, 1) === '.') || in_array(substr($file, -1), array('~', '#'))) { continue; } - if (preg_match("/^(^\.)/", $file) === 0) { - if (is_dir($directory . "/" . $file)) { - $array_items = array_merge($array_items, $this->get_files($directory . "/" . $file, $ext)); - } else { - $file = $directory . "/" . $file; - if (!$ext || strstr($file, $ext)) { - $array_items[] = preg_replace("/\/\//si", "/", $file); - } - } + + if (is_dir($directory . '/' . $file)) { + // get files recursively + $result = array_merge($result, $this->getFiles($directory . '/' . $file, $fileExtension)); + } elseif (empty($fileExtension) || (substr($file, -strlen($fileExtension)) === $fileExtension)) { + $result[] = $directory . '/' . $file; } } } - return $array_items; + return $result; } /** - * Helper function to limit the words in a string + * Triggers events on plugins which implement {@link IPicoPlugin} * - * @param string $string the given string - * @param int $word_limit the number of words to limit to - * @return string the limited string + * Deprecated events (as used by plugins not implementing + * {@link IPocPlugin}) are triggered by {@link PicoDeprecated}. + * + * @param string $eventName name of the event to trigger + * @param array $params optional parameters to pass + * @return void */ - protected function limit_words($string, $word_limit) + protected function triggerEvent($eventName, array $params = array()) { - $words = explode(' ', $string); - $excerpt = trim(implode(' ', array_splice($words, 0, $word_limit))); - if (count($words) > $word_limit) { - $excerpt .= '…'; + foreach ($this->plugins as $plugin) { + // only trigger events for plugins that implement IPicoPlugin + // deprecated events (plugins for Pico 0.9 and older) will be + // triggered by the `PicoPluginDeprecated` plugin + if (is_a($plugin, 'IPicoPlugin')) { + $plugin->handleEvent($eventName, $params); + } } - - return $excerpt; } - }