diff --git a/.htaccess b/.htaccess index 553cd573d..389bb6d3e 100644 --- a/.htaccess +++ b/.htaccess @@ -4,7 +4,12 @@ #RewriteBase / RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d - RewriteRule . index.php [L] + RewriteRule ^(.*)$ index.php?$1 [L,QSA] + RewriteRule ^(config|content|content-sample|lib|vendor)/.* - [R=404,L] + + + SetEnv PICO_URL_REWRITING 1 + # Prevent file browsing diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..63fd1fcf0 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,29 @@ +language: php +php: + - 5.3 + - 5.4 + - 5.5 + - 5.6 + - 7 + - hhvm + - nightly + +script: + - find . -type f -name '*.php' -print0 | xargs -0 -I file php -l file > /dev/null + +before_deploy: + - composer install + - tar -czf "pico-release-$TRAVIS_TAG.tar.gz" .htaccess README.md changelog.txt composer.json composer.lock license.txt config content-sample lib plugins themes vendor index.php + +deploy: + provider: releases + api_key: ${GITHUB_OAUTH_TOKEN} + file: pico-release-$TRAVIS_TAG.tar.gz + skip_cleanup: true + on: + repo: picocms/Pico + tags: true + php: 5.3 + +sudo: false + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..8ebdff1c1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,197 @@ +Pico Changelog +============== + +### Version 1.0.0-beta.1 +Released: 2015-11-06 + +**Note:** This changelog only provides basic information about the enormous + changes introduced with Pico 1.0.0-beta.1. Please refer to the + UGPRADE section of the docs for details. + +``` +* [Security] (9e2604a) Prevent content_dir breakouts using malicious URLs +* [New] Pico is on its way to its first stable release! +* [New] Provide pre-bundled releases +* [New] Heavily expanded documentation (inline code docs, user docs, dev docs) +* [New] New routing system using the QUERY_STRING method; Pico now works + out-of-the-box with any webserver and without URL rewriting; use + `%base_url%?sub/page` in markdown files and `{{ "sub/page"|link }}` + in Twig templates to declare internal links +* [New] Brand new plugin system with dependencies (see `PicoPluginInterface` + and `AbstractPicoPlugin`); if you're plugin dev, you really should + take a look at the UPGRADE section of the docs! +* [New] Introducing the `PicoDeprecated` plugin to maintain full backward + compatibility with Pico 0.9 and Pico 0.8 +* [New] Support YAML-style meta header comments (`---`) +* [New] Various new placeholders to use in content files (e.g. `%site_title%`) +* [New] Provide access to all meta headers in content files (`%meta.*%`) +* [New] Provide access to meta headers in `$page` arrays (`$page['meta']`) +* [New] The file extension of content files is now configurable +* [New] Add `Pico::setConfig()` method to predefine config variables +* [New] Supporting per-directory `404.md` files +* [New] #103: Providing access to `sub.md` even when the `sub` directory + exists, provided that there is no `sub/index.md` +* [New] #249: Support the `.twig` file extension for templates +* [New] #268, 269: Now using Travis CI; performing basic code tests and + implementing an automatic release process +* [Changed] Complete code refactoring +* [Changed] Source code now follows PSR code styling +* [Changed] Replacing constants (e.g. `ROOT_DIR`) with constructor parameters +* [Changed] Paths (e.g. `content_dir`) are now relative to Pico's root dir +* [Changed] Adding `Pico::run()` method that performs Pico's processing and + returns the rendered contents +* [Changed] Renaming all plugin events; adding some new events +* [Changed] `Pico_Plugin` is now the fully documented `DummyPlugin` +* [Changed] Meta data must start on the first line of the file now +* [Changed] Dropping the need to register meta headers for the convenience of + users and pure (!) theme devs; plugin devs are still REQUIRED to + register their meta headers during `onMetaHeaders` +* [Changed] Exclude inaccessible files from pages list +* [Changed] With alphabetical order, index files (e.g. `sub/index.md`) are + now always placed before their sub pages (e.g. `sub/foo.md`) +* [Changed] Pico requires PHP >= 5.3.6 (due to `erusev/parsedown-extra`) +* [Changed] Pico now implicitly uses a existing `content` directory without + the need to configure this in the `config/config.php` explicitly +* [Changed] Composer: Require a v0.7 release of `erusev/parsedown-extra` +* [Changed] #93, #158: Pico doesn't parse all content files anymore; moved to + `PicoParsePagesContent` plugin, but still impacts performance; + Note: This means `$page['content']` isn't available anymore, but + usually the new `$page['raw_content']` is suitable as replacement. +* [Changed] #116: Parse meta headers using the Symfony YAML component +* [Changed] #244: Replace opendir() with scandir() +* [Changed] #246: Move `config.php` to `config/` directory +* [Changed] #253: Assume HTTPS if page is requested through port 443 +* [Changed] A vast number of small improvements and changes... +* [Fixed] Sorting by date now uses timestamps and works as expected +* [Fixed] Fixing `$currentPage`, `$nextPage` and `$previousPage` +* [Fixed] #99: Support content filenames with spaces +* [Fixed] #140, #241: Use file paths as page identifiers rather than titles +* [Fixed] #248: Always set a timezone; adding `$config['timezone']` option +* [Fixed] A vast number of small bugs... +* [Removed] Removing the default Twig cache dir +* [Removed] Removing various empty `index.html` files +* [Removed] Moving Pico's excerpt feature to `PicoExcerpt` plugin +``` + +### Version 0.9 +Released: 2015-04-28 + +``` +* [New] Default theme is now mobile-friendly +* [New] Description meta now available in content areas +* [New] Add description to composer.json +* [Changed] content folder is now content-sample +* [Changed] config.php moved to config.php.template +* [Changed] Updated documentation & wiki +* [Changed] Removed Composer, Twig files in /vendor, you must run composer + install now +* [Changed] Localized date format; strftime() instead of date() +* [Changed] Added ignore for tmp file extensions in the get_files() method +* [Changed] michelf/php-markdown is replaced with erusev/parsedown-extra +* [Changed] $config is no global variable anymore +* [Fixed] Pico now only removes the 1st comment block in .md files +* [Fixed] Issue wherein the alphabetical sorting of pages did not happen +``` + +### Version 0.8 +Released: 2013-10-23 + +``` +* [New] Added ability to set template in content meta +* [New] Added before_parse_content and after_parse_content hooks +* [Changed] content_parsed hook is now deprecated +* [Changed] Moved loading the config to nearer the beginning of the class +* [Changed] Only append ellipsis in limit_words() when word count exceeds max +* [Changed] Made private methods protected for better inheritance +* [Fixed] Fixed get_protocol() method to work in more situations +``` + +### Version 0.7 +Released: 2013-09-04 + +``` +* [New] Added before_read_file_meta and get_page_data plugin hooks to customize + page meta data +* [Changed] Make get_files() ignore dotfiles +* [Changed] Make get_pages() ignore Emacs and temp files +* [Changed] Use composer version of Markdown +* [Changed] Other small tweaks +* [Fixed] Date warnings and other small bugs +``` + +### Version 0.6.2 +Released: 2013-05-07 + +``` +* [Changed] Replaced glob_recursive with get_files +``` + +### Version 0.6.1 +Released: 2013-05-07 + +``` +* [New] Added "content" and "excerpt" fields to pages +* [New] Added excerpt_length config setting +``` + +### Version 0.6 +Released: 2013-05-06 + +``` +* [New] Added plugin functionality +* [Changed] Other small cleanup +``` + +### Version 0.5 +Released: 2013-05-03 + +``` +* [New] Added ability to order pages by "alpha" or "date" (asc or desc) +* [New] Added prev_page, current_page, next_page and is_front_page template vars +* [New] Added "Author" and "Date" title meta fields +* [Changed] Added "twig_config" to settings +* [Changed] Updated documentation +* [Fixed] Query string 404 bug +``` + +### Version 0.4.1 +Released: 2013-05-01 + +``` +* [New] Added CONTENT_EXT global +* [Changed] Use .md files instead of .txt +``` + +### Version 0.4 +Released: 2013-05-01 + +``` +* [New] Add get_pages() function for listing content +* [New] Added changelog.txt +* [Changed] Updated default theme +* [Changed] Updated documentation +``` + +### Version 0.3 +Released: 2013-04-27 + +``` +* [Fixed] get_config() function +``` + +### Version 0.2 +Released: 2013-04-26 + +``` +* [Changed] Updated Twig +* [Changed] Better checking for HTTPS +* [Fixed] Add 404 header to 404 page +* [Fixed] Case sensitive folder bug +``` + +### Version 0.1 +Released: 2012-04-04 + +``` +* Initial release +``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..6383c97f6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,119 @@ +Contributing to Pico +==================== + +Pico aims to be a high quality Content Management System (CMS) but at the same time wants to give contributors freedom when submitting fixes or improvements. + +As such we want to *encourage* but not obligate you, the contributor, to follow these guidelines. The only exception to this are the guidelines elucidated in the *Prevent `merge-hell`* section. + +Having said that: we really appreciate it when you apply the guidelines in part or wholly as that will save us time which, in turn, we can spend on bugfixes and new features. + +Issues +------ + +If you want to report an *issue* with Pico's core, please create a new [Issue](https://github.com/picocms/Pico/issues) on GitHub. Concerning problems with plugins or themes, please refer to the website of the developer of this plugin or theme. + +Before creating a [new Issue on GitHub](https://github.com/picocms/Pico/issues/new), please make sure the problem wasn't reported yet using [GitHubs search engine](https://github.com/picocms/Pico/search?type=Issues). Please describe your issue as clear as possible and always include steps to reproduce the problem. + +Contributing code +----------------- + +Once you decide you want to contribute to *Pico's core* (which we really appreciate!) you can fork the project from https://github.com/picocms/Pico. If you're interested in developing a *plugin* or *theme* for Pico, please refer to the [development section](http://picocms.org/plugin-dev.html) of our website. + +### Prevent `merge-hell` + +Please do *not* develop your contribution on the `master` branch of your fork, but create a separate feature branch, that is based off the `master` branch, for each feature that you want to contribute. + +> Not doing so means that if you decide to work on two separate features and place a pull request for one of them, that the changes of the other issue that you are working on is also submitted. Even if it is not completely finished. + +To get more information about the usage of Git, please refer to the [Pro Git book](https://git-scm.com/book) written by Scott Chacon and/or [this help page of GitHub](https://help.github.com/articles/using-pull-requests). + +### Pull Requests + +Please keep in mind that pull requests should be small (i.e. one feature per request), stick to existing coding conventions and documentation should be updated if required. It's encouraged to make commits of logical units and check for unnecessary whitespace before committing (try `git diff --check`). Please reference issue numbers in your commit messages where appropriate. + +### Coding Standards + +Pico uses the [PSR-2 Coding Standard](http://www.php-fig.org/psr/psr-2/) as defined by the [PHP Framework Interoperability Group (PHP-FIG)](http://www.php-fig.org/). + +For historical reasons we don't use formal namespaces. Markdown files in the `content-sample` folder (the inline documentation) must follow a hard limit of 80 characters line length. + +It is recommended to check your code using [PHP_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer) using the `PSR2` standard using the following command: + + $ ./bin/phpcs --standard=PSR2 [file(s)] + +With this command you can specify a file or folder to limit which files it will check or omit that argument altogether, in which case the current directory is checked. + +### Keep documentation in sync + +Pico accepts the problems of having redundant documentation on different places (concretely Pico's inline user docs, the `README.md` and the website) for the sake of a better user experience. When updating the docs, please make sure the keep them in sync. + +If you update the [`README.md`](https://github.com/picocms/Pico/blob/master/README.md) or [`content-sample/index.md`](https://github.com/picocms/Pico/blob/master/content-sample/index.md), please make sure to update the corresponding files in the [`_docs`](https://github.com/picocms/Pico/tree/gh-pages/_docs/) folder of the `gh-pages` branch (i.e. [Pico's website](http://picocms.org/docs.html)) and vice versa. Unfortunately this involves three (!) different markdown parsers. If you're experiencing problems, use Pico's [`erusev/parsedown-extra`](https://github.com/erusev/parsedown-extra) as a reference. You can try to make the contents compatible to [Redcarpet](https://github.com/vmg/redcarpet) by yourself, otherwise please address the issues in your pull request message and we'll take care of it. + +Versioning +---------- + +Pico follows [Semantic Versioning 2.0](http://semver.org) and uses version numbers like `MAJOR`.`MINOR`.`PATCH`. We will increment the: + +- `MAJOR` version when we make incompatible API changes, +- `MINOR` version when we add functionality in a backwards-compatible manner, and +- `PATCH` version when we make backwards-compatible bug fixes. + +For more information please refer to the http://semver.org website. + +Branching +--------- + +The `master` branch contains the current development version of Pico. It is likely *unstable* and *not ready for production use*. However, the `master` branch always consists of a deployable version of Pico. + +Pico's actual development happens in separate development branches. Development branches are prefixed by: + +- `feature/` for bigger features, +- `enhancement/` for smaller improvements, and +- `bugfix/` for bug fixes. + +As soon as development reaches a point where feedback is appreciated, a [pull request](https://github.com/picocms/Pico/pulls) is opened. After some time (very soon for bug fixes, and other improvements should have a reasonable feedback phase) the pull request is merged into `master` and the development branch will be deleted. + +Build & Release process +----------------------- + +This is work in progress. Please refer to [#268](https://github.com/picocms/Pico/issues/268) for details. + + diff --git a/license.txt b/LICENSE similarity index 100% rename from license.txt rename to LICENSE diff --git a/README.md b/README.md index dd2db9d7f..520d937f6 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,120 @@ -# Pico +Pico +==== -[![License](https://img.shields.io/packagist/l/doctrine/orm.svg)](https://scrutinizer-ci.com/g/theshka/Pico/build-status/LICENSE) -[![Version](https://img.shields.io/badge/version-0.9-lightgrey.svg)]() -[![Build Status](https://scrutinizer-ci.com/g/theshka/Pico/badges/build.png?b=master)](https://scrutinizer-ci.com/g/theshka/Pico/build-status/master) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/theshka/Pico/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/theshka/Pico/?branch=master) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/picocms/Pico/blob/master/LICENSE) +[![Version](https://img.shields.io/badge/version-1.0-lightgrey.svg)](https://github.com/picocms/Pico/releases/latest) +[![Build Status](https://travis-ci.org/picocms/Pico.svg)](https://travis-ci.org/picocms/Pico) +[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/theshka/Pico/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/theshka/Pico/?branch=master) Pico is a stupidly simple, blazing fast, flat file CMS. See http://picocms.org/ for more info. [![I Love Open Source](http://www.iloveopensource.io/images/logo-lightbg.png)](http://www.iloveopensource.io/projects/524c55dcca7964c617000756) -Requirements ---- -Requires PHP 5.3+ +Install +------- -Download ---- -You can install the latest version either by downloading it from or use Git: +You can install Pico either using a pre-bundled release or with composer. Pico is also available on [Packagist.org][] and may be included in other projects via `composer require picocms/pico`. Pico requires PHP 5.3+ + +#### Using a pre-bundled release + +Just [download the latest Pico release][LatestRelease] and upload all files to the `httpdocs` directory (e.g. `/var/www/html`) of your server. + +#### Composer + +###### Step 1 - for users +[Download the *source code* of Pico's latest release][LatestRelease], upload all files to the `httpdocs` directory (e.g. `/var/www/html`) of your server and navigate to the upload directory using a shell. +###### Step 1 - for developers +Open a shell and navigate to the desired install directory of Pico within the `httpdocs` directory (e.g. `/var/www/html`) of your server. You can now clone Pico's Git repository as follows: ```shell -git clone https://github.com/picocms/Pico.git +$ git clone https://github.com/picocms/Pico.git . ``` +Please note that this gives you the current development version of Pico, what is likely *unstable* and *not ready for production use*! -Install ---- -Download [composer]() and run it with install option. +###### Step 2 +Download [composer][] and run it with the `install` option: +```shell +$ curl -sS https://getcomposer.org/installer | php +$ php composer.phar install +``` + +Upgrade +------- + +Upgrading Pico is very easy: You just have to replace all of Pico's files - that's it! Nevertheless you should *always* create a backup of your Pico installation before upgrading. + +Pico follows [Semantic Versioning 2.0][SemVer] and uses version numbers like `MAJOR`.`MINOR`.`PATCH`. When we update... - $ curl -sS https://getcomposer.org/installer | php - $ php composer.phar install +- the `PATCH` version (e.g. `1.0.0` to `1.0.1`), we made backwards-compatible bug fixes. It's then sufficient to extract [Pico's latest release][LatestRelease] to your existing installation directory and overwriting all files. + +- the `MINOR` version (e.g. `1.0` to `1.1`), we added functionality in a backwards-compatible manner, but anyway recommend you to "install" Pico newly. Backup all of your files, empty your installation directory and install Pico as elucidated above. You can then copy your `config/config.php` and `content` directory without any change. If applicable, you can also copy the folder of your custom theme within the `themes` directory. Provided that you're using plugins, also copy all of your plugins from the `plugins` directory. + +- the `MAJOR` version (e.g. `1.0` to `2.0`), a appropriate upgrade tutorial will be provided. + +Upgrading Pico 0.8 or 0.9 to Pico 1.0 is a special case. The new `PicoDeprecated` plugin ensures backwards compatibility, so you basically can follow the above upgrade instructions as if we updated the `MINOR` version. However, we recommend you to take some further steps to confine the necessity of `PicoDeprecated` as far as possible. For more information about what has changed with Pico 1.0 and a step-by-step upgrade tutorial, please refer to the [upgrade page of our website][HelpUpgrade]. Run --- -The easiest way to Pico is using [the built-in web server on PHP](). +You have nothing to consider specially, simply navigate to your Pico install using your favorite web browser. Pico's default contents will explain how to use your brand new, stupidly simple, blazing fast, flat file CMS. - $ php -S 0.0.0.0:8080 +#### You don't have a web server? +Starting with PHP 5.4 the easiest way to try Pico is using [the built-in web server of PHP][PHPServer]. Please note that PHPs built-in web server is for development and testing purposes only! -Pico will be accessible from . +###### Step 1 +Navigate to Pico's installation directory using a shell. -Getting Help ---- -You can read the wiki if you are looking for examples and read the inline-docs for more development information. +###### Step 2 +Start PHPs built-in web server: +```shell +$ php -S 127.0.0.1:8080 +``` -If you find a bug please report it on the issues page, but remember to include as much detail as possible, and what someone can do to re-create the issue. +###### Step 3 +Access Pico from http://localhost:8080. -Issues with plugins should be reported on the offending plugins homepage, same goes for themes. +Getting Help +------------ -Contributing ---- -Help make PicoCMS better by checking out the GitHub repository and submitting pull requests. +#### Getting Help as a user +If you want to get started using Pico, please refer to our [user docs][HelpUserDocs]. Please read the [upgrade notes][HelpUpgrade] if you want to upgrade from Pico 0.8 or 0.9 to Pico 1.0. You can find officially supported plugins and themes on [our website][OfficialPlugins]. A greater choice of third-party plugins and themes can be found in our [Wiki][] on the [plugins][WikiPlugins] or [themes][WikiThemes] pages respectively. If you want to create your own plugin or theme, please refer to the "Getting Help as a developer" section below. -If you create a plugin please add it to the Wiki. +#### Getting Help as a developer +If you're a developer, please refer to the "Contributing" section below and our [contribution guidelines][ContributionGuidelines]. To get you started with creating a plugin or theme, please read the [dev docs on our website][HelpDevDocs]. -Plugins + Wiki ---- -Pico can be extended with a wide variety of plugins in order to add extra functionality, speed, or features. +#### You still need help or experience a problem with Pico? +When the docs can't answer your question or when you're experiencing problems with Pico, please don't hesitate to create a new [Issue][Issues] on GitHub. Concerning problems with plugins or themes, please refer to the website of the developer of this plugin or theme. + +**Before creating a new Issue,** please make sure the problem wasn't reported yet using [GitHubs search engine][IssuesSearch]. Please describe your issue as clear as possible and always include steps to reproduce the problem. -Visit the [Pico Wiki](https://github.com/picocms/Pico/wiki) for docs, plugins, themes, etc... +Contributing +------------ + +You want to contribute to Pico? We really appreciate that! You can help make Pico better by [contributing code][PullRequests] or [reporting issues][Issues], but please take note of our [contribution guidelines][ContributionGuidelines]. In general you can contribute in three different areas: + +1. Plugins & Themes: You're a plugin developer or theme designer? We love you guys! You can find tons of information about how to develop plugins and themes at http://picocms.org/plugin-dev.html. If you have created a plugin or theme, please add it to our [Wiki][], either on the [plugins][WikiPlugins] or [themes page][WikiThemes]. Doing so, we may select and promote your plugin or theme on [our website][OfficialPlugins] as officially supported! + +2. Documentation: We always appreciate people improving our documentation. You can either improve the [inline user docs][EditInlineDocs] or the more extensive [user docs on our website][EditUserDocs]. You can also improve the [docs for plugin and theme developers][EditDevDocs]. Simply fork Pico from https://github.com/picocms/Pico, change the Markdown files and open a [pull request][PullRequests]. + +3. Pico's Core: The supreme discipline is to work on Pico's Core. Your contribution should help *every* Pico user to have a better experience with Pico. If this is the case, fork Pico from https://github.com/picocms/Pico and open a [pull request][PullRequests]. We look forward to your contribution! + +[Packagist.org]: http://packagist.org/packages/picocms/pico +[LatestRelease]: https://github.com/picocms/Pico/releases/latest +[composer]: https://getcomposer.org/ +[SemVer]: http://semver.org +[PHPServer]: http://php.net/manual/en/features.commandline.webserver.php +[HelpUpgrade]: http://picocms.org/upgrade.html +[HelpUserDocs]: http://picocms.org/docs.html +[HelpDevDocs]: http://picocms.org/plugin-dev.html +[OfficialPlugins]: http://picocms.org/plugins.html +[Wiki]: https://github.com/picocms/Pico/wiki +[WikiPlugins]: https://github.com/picocms/Pico/wiki/Pico-Plugins +[WikiThemes]: https://github.com/picocms/Pico/wiki/Pico-Themes +[Issues]: https://github.com/picocms/Pico/issues +[IssuesSearch]: https://github.com/picocms/Pico/search?type=Issues +[PullRequests]: https://github.com/picocms/Pico/pulls +[ContributionGuidelines]: https://github.com/picocms/Pico/blob/master/CONTRIBUTING.md +[EditInlineDocs]: https://github.com/picocms/Pico/blob/master/content-sample/index.md +[EditUserDocs]: https://github.com/picocms/Pico/tree/gh-pages/_docs +[EditDevDocs]: https://github.com/picocms/Pico/tree/gh-pages/_plugin-dev diff --git a/changelog.txt b/changelog.txt deleted file mode 100644 index f8035ad21..000000000 --- a/changelog.txt +++ /dev/null @@ -1,70 +0,0 @@ -*** Pico Changelog *** - -2015.04.28 - version 0.9 - * [New] Default theme is now mobile-friendly - * [New] Description meta now available in content areas - * [Changed] content folder is now content-sample - * [Changed] Updated documentation & wiki - * [Changed] Removed Composer, Twig files in /vendor, you must run composer install now - * [Changed] Localized date format; strftime() instead of date() - * [Changed] Added ignore for tmp file extensions in the get_files() method - * [Fixed] Pico now only removes the 1st comment block in .md file - * [Fixed] Issue wherein the alphabetical sorting of pages did not happen - -2013.10.23 - version 0.8 - * [New] Added ability to set template in content meta - * [New] Added before_parse_content and after_parse_content hooks - * [Changed] content_parsed hook is now depreciated - * [Changed] Moved loading the config to nearer the beginning of the class - * [Changed] Only append ellipsis in limit_words() when word count exceeds max - * [Changed] Made private methods protected for better inheritance - * [Fixed] Fixed get_protocol() method to work in more situations - -2013.09.04 - version 0.7 - * [New] Added before_read_file_meta and get_page_data plugin hooks to customize page meta data - * [Changed] Make get_files() ignore dotfiles - * [Changed] Make get_pages() ignore Emacs and temp files - * [Changed] Use composer version of Markdown - * [Changed] Other small tweaks - * [Fixed] Date warnings and other small bugs - -2013.05.07 - version 0.6.2 - * [Changed] Replaced glob_recursive with get_files - -2013.05.07 - version 0.6.1 - * [New] Added "content" and "excerpt" fields to pages - * [New] Added excerpt_length config setting - -2013.05.06 - version 0.6 - * [New] Added plugin functionality - * [Changed] Other small cleanup - -2013.05.03 - version 0.5 - * [New] Added ability to order pages by "alpha" or "date" (asc or desc) - * [New] Added prev_page, current_page, next_page and is_front_page template vars - * [New] Added "Author" and "Date" title meta fields - * [Changed] Added "twig_config" to settings - * [Changed] Updated documentation - * [Fixed] Query string 404 bug - -2013.05.01 - version 0.4.1 - * [New] Added CONTENT_EXT global - * [Changed] Use .md files instead of .txt - -2013.05.01 - version 0.4 - * [New] Add get_pages() function for listing content - * [New] Added changelog.txt - * [Changed] Updated default theme - * [Changed] Updated documentation - -2013.04.27 - version 0.3 - * [Fixed] get_config() function - -2013.04.26 - version 0.2 - * [Changed] Updated Twig - * [Changed] Better checking for HTTPS - * [Fixed] Add 404 header to 404 page - * [Fixed] Case sensitive folder bug - -2012.04.04 - version 0.1 - * Initial release diff --git a/composer.json b/composer.json index 40d4c0625..b0d75e102 100644 --- a/composer.json +++ b/composer.json @@ -12,11 +12,16 @@ } ], "require": { - "php": ">=5.3.2", + "php": ">=5.3.6", "twig/twig": "1.18.*", - "erusev/parsedown-extra": "dev-master@dev" + "erusev/parsedown-extra": "0.7.*", + "symfony/yaml" : "2.3" }, "autoload": { - "files": ["lib/pico.php"] + "psr-0": { + "Pico": "lib/", + "PicoPluginInterface": "lib/", + "AbstractPicoPlugin": "lib/" + } } } diff --git a/config/config.php.template b/config/config.php.template index 5d186b17a..0d44037eb 100644 --- a/config/config.php.template +++ b/config/config.php.template @@ -1,18 +1,19 @@ false, // To enable Twig caching change this to CACHE_DIR -// 'autoescape' => false, // Autoescape Twig vars -// 'debug' => false // Enable Twig debug +// 'cache' => false, // To enable Twig caching change this to a path to a writable directory +// 'autoescape' => false, // Auto-escape Twig vars +// 'debug' => false // Enable Twig debug // ); /* * CONTENT */ -// $config['date_format'] = '%D %T'; // Set the PHP date format as described here: http://php.net/manual/en/function.strftime.php -// $config['pages_order_by'] = 'alpha'; // Order pages by "alpha" or "date" -// $config['pages_order'] = 'asc'; // Order pages "asc" or "desc" -// $config['excerpt_length'] = 50; // The pages excerpt length (in words) -// $config['content_dir'] = 'content-sample/'; // Content directory +// $config['date_format'] = '%D %T'; // Set the PHP date format as described here: http://php.net/manual/en/function.strftime.php +// $config['pages_order_by'] = 'alpha'; // Order pages by "alpha" or "date" +// $config['pages_order'] = 'asc'; // Order pages "asc" or "desc" +// $config['content_dir'] = 'content-sample/'; // Content directory +// $config['content_ext'] = '.md'; // File extension of content files to serve /* * TIMEZONE */ -// date_default_timezone_set('UTC'); // Timezone may be reqired by your php install +// $config['timezone'] = 'UTC'; // Timezone may be required by your php install /* - * CUSTOM + * PLUGINS */ -// $config['custom_setting'] = 'Hello'; // Can be accessed by {{ config.custom_setting }} in a theme +// $config['DummyPlugin.enabled'] = false; // Force DummyPlugin to be disabled -// Keep this line -return $config; +/* + * CUSTOM + */ +// $config['custom_setting'] = 'Hello'; // Can be accessed by {{ config.custom_setting }} in a theme diff --git a/content-sample/404.md b/content-sample/404.md index ef7988eac..e6ddf3072 100644 --- a/content-sample/404.md +++ b/content-sample/404.md @@ -1,9 +1,9 @@ -/* +--- Title: Error 404 Robots: noindex,nofollow -*/ +--- Error 404 ========= -Woops. Looks like this page doesn't exist. \ No newline at end of file +Woops. Looks like this page doesn't exist. diff --git a/content-sample/index.html b/content-sample/index.html deleted file mode 100644 index e69de29bb..000000000 diff --git a/content-sample/index.md b/content-sample/index.md index 8ebddacb9..074ded80e 100644 --- a/content-sample/index.md +++ b/content-sample/index.md @@ -1,109 +1,286 @@ -/* +--- Title: Welcome -Description: This description will go in the meta description tag -*/ +Description: Pico is a stupidly simple, blazing fast, flat file CMS. +--- ## Welcome to Pico -Congratulations, you have successfully installed [Pico](http://picocms.org/). Pico is a stupidly simple, blazing fast, flat file CMS. +Congratulations, you have successfully installed [Pico](http://picocms.org/). +%meta.description% -### Creating Content +## Creating Content -Pico is a flat file CMS, this means there is no administration backend and database to deal with. You simply create `.md` files in the "content-sample" -folder and that becomes a page. For example, this file is called `index.md` and is shown as the main landing page. +Pico is a flat file CMS, this means there is no administration backend or +database to deal with. You simply create `.md` files in the `content-sample` +folder and that becomes a page. For example, this file is called `index.md` +and is shown as the main landing page. -If you create a folder within the content-sample folder (e.g. `content-sample/sub`) and put an `index.md` inside it, you can access that folder at the URL -`http://yoursite.com/sub`. If you want another page within the sub folder, simply create a text file with the corresponding name (e.g. `content-sample/sub/page.md`) -and you will be able to access it from the URL `http://yoursite.com/sub/page`. Below we've shown some examples of content-sample locations and their corresponing URL's: +If you create a folder within the content folder (e.g. `content-sample/sub`) +and put an `index.md` inside it, you can access that folder at the URL +`http://example.com/pico/?sub`. If you want another page within the sub folder, +simply create a text file with the corresponding name and you will be able to +access it (e.g. `content-sample/sub/page.md` is accessible from the URL +`http://example.com/pico/?sub/page`). Below we've shown some examples of +locations and their corresponding URLs: - - - - - - - - - - - +
Physical LocationURL
content-sample/index.md/
content-sample/sub.md/sub
content-sample/sub/index.md/sub (same as above)
content-sample/sub/page.md/sub/page
content-sample/a/very/long/url.md/a/very/long/url
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Physical LocationURL
content-sample/index.md/
content-sample/sub.md?sub (not accessible, see below)
content-sample/sub/index.md?sub (same as above)
content-sample/sub/page.md?sub/page
content-sample/a/very/long/url.md?a/very/long/url (doesn't exist)
-If a file cannot be found, the file `content-sample/404.md` will be shown. +If a file cannot be found, the file `content-sample/404.md` will be shown. You +can add `404.md` files to any directory, so if you want to use a special error +page for your blog, simply create `content-sample/blog/404.md`. + +Instead of adding your own content to the `content-sample` folder, you should +create your own `content` directory in Pico's root directory. You can then add +and access your contents as described above. ### Text File Markup -Text files are marked up using [Markdown](http://daringfireball.net/projects/markdown/syntax). They can also contain regular HTML. +Text files are marked up using [Markdown][]. They can also contain regular HTML. -At the top of text files you can place a block comment and specify certain attributes of the page. For example: +At the top of text files you can place a block comment and specify certain +attributes of the page. For example: - /* - Title: Welcome - Description: This description will go in the meta description tag - Author: Joe Bloggs - Date: 2013/01/01 - Robots: noindex,nofollow - */ + --- + Title: Welcome + Description: This description will go in the meta description tag + Author: Joe Bloggs + Date: 2013/01/01 + Robots: noindex,nofollow + Template: index + --- -These values will be contained in the `{{ meta }}` variable in themes (see below). +These values will be contained in the `{{ meta }}` variable in themes +(see below). There are also certain variables that you can use in your text files: -* %base_url% - The URL to your Pico site +* %site_title% - The title of your Pico site +* %base_url% - The URL to your Pico site; internal links + can be specified using %base_url%?sub/page +* %theme_url% - The URL to the currently used theme +* %meta.*% - Access any meta variable of the current page, + e.g. %meta.author% is replaced with `Joe Bloggs` + +### Blogging + +Pico is not blogging software - but makes it very easy for you to use it as a +blog. You can find many plugins out there implementing typical blogging +features like authentication, tagging, pagination and social plugins. See the +below Plugins section for details. + +If you want to use Pico as a blogging software, you probably want to do +something like the following: +1. Put all your blog articles in a separate `blog` folder in your `content` + directory. All these articles should have both a `Date` and `Template` meta + header, the latter with e.g. `blog-post` as value (see Step 2). +2. Create a new Twig template called `blog-post.twig` (this must match the + `Template` meta header from Step 1) in your theme directory. This template + probably isn't very different from your default `index.twig`, it specifies + how your article pages will look like. +3. Create a `blog.md` in your `content` folder and set its `Template` meta + header to e.g. `blog`. Also create a `blog.twig` in your theme directory. + This template will show a list of your articles, so you probably want to + do something like this: + ``` + {% for page in pages %} + {% if page.id starts with "blog/" %} +
+

{{ page.title }}

+

{{ page.date_formatted }}

+

{{ page.description }}

+
+ {% endif %} + {% endfor %} + ``` +4. Let Pico sort pages by date by setting `$config['pages_order_by'] = 'date';` + in your `config/config.php`. To use a descending order (newest articles + first), also add `$config['pages_order'] = 'desc';`. The former won't affect + pages without a `Date` meta header, but the latter does. To use ascending + order for your page navigation again, add Twigs `reverse` filter to the + navigation loop (`{% for page in pages|reverse %}...{% endfor %}`) in your + themes `index.twig`. +5. Make sure to exclude the blog articles from your page navigation. You can + achieve this by adding `{% if not page starts with "blog/" %}...{% endif %}` + to the navigation loop. + +## Customization + +Pico is highly customizable in two different ways: On the one hand you can +change Pico's appearance by using themes, on the other hand you can add new +functionality by using plugins. Doing the former includes changing Pico's HTML, +CSS and JavaScript, the latter mostly consists of PHP programming. + +This is all Greek to you? Don't worry, you don't have to spend time on these +techie talk - it's very easy to use one of the great themes or plugins others +developed and released to the public. Please refer to the next sections for +details. ### Themes -You can create themes for your Pico installation in the "themes" folder. Check out the default theme for an example of a theme. Pico uses -[Twig](http://twig.sensiolabs.org/documentation) for it's templating engine. You can select your theme by setting the `$config['theme']` variable -in `config/config.php` to your theme folder. +You can create themes for your Pico installation in the `themes` folder. Check +out the default theme for an example. Pico uses [Twig][] for template +rendering. You can select your theme by setting the `$config['theme']` option +in `config/config.php` to the name of your theme folder. -All themes must include an `index.html` file to define the HTML structure of the theme. Below are the Twig variables that are available to use in your theme: +All themes must include an `index.twig` (or `index.html`) file to define the +HTML structure of the theme. Below are the Twig variables that are available +to use in your theme. Please note that paths (e.g. `{{ base_dir }}`) and URLs +(e.g. `{{ base_url }}`) don't have a trailing slash. -* `{{ config }}` - Conatins the values you set in `config/config.php` (e.g. `{{ config.theme }}` = "default") +* `{{ config }}` - Conatins the values you set in `config/config.php` + (e.g. `{{ config.theme }}` becomes `default`) * `{{ base_dir }}` - The path to your Pico root directory -* `{{ base_url }}` - The URL to your Pico site -* `{{ theme_dir }}` - The path to the Pico active theme directory -* `{{ theme_url }}` - The URL to the Pico active theme directory -* `{{ site_title }}` - Shortcut to the site title (defined in `config/config.php`) +* `{{ base_url }}` - The URL to your Pico site; use Twigs `link` filter to + specify internal links (e.g. `{{ "sub/page"|link }}`), + this guarantees that your link works whether URL rewriting + is enabled or not +* `{{ theme_dir }}` - The path to the currently active theme +* `{{ theme_url }}` - The URL to the currently active theme +* `{{ rewrite_url }}` - A boolean flag indicating enabled/disabled URL rewriting +* `{{ site_title }}` - Shortcut to the site title (see `config/config.php`) * `{{ meta }}` - Contains the meta values from the current page - * `{{ meta.title }}` - * `{{ meta.description }}` - * `{{ meta.author }}` - * `{{ meta.date }}` - * `{{ meta.date_formatted }}` - * `{{ meta.robots }}` -* `{{ content-sample }}` - The content-sample of the current page (after it has been processed through Markdown) -* `{{ pages }}` - A collection of all the content-sample in your site - * `{{ page.title }}` - * `{{ page.url }}` - * `{{ page.author }}` - * `{{ page.date }}` - * `{{ page.date_formatted }}` - * `{{ page.content-sample }}` - * `{{ page.excerpt }}` -* `{{ prev_page }}` - A page object of the previous page (relative to current_page) -* `{{ current_page }}` - A page object of the current_page -* `{{ next_page }}` - A page object of the next page (relative to current_page) + * `{{ meta.title }}` + * `{{ meta.description }}` + * `{{ meta.author }}` + * `{{ meta.date }}` + * `{{ meta.date_formatted }}` + * `{{ meta.time }}` + * `{{ meta.robots }}` + * ... +* `{{ content }}` - The content of the current page + (after it has been processed through Markdown) +* `{{ pages }}` - A collection of all the content pages in your site + * `{{ page.id }}` - The relative path to the content file (unique ID) + * `{{ page.url }}` - The URL to the page + * `{{ page.title }}` - The title of the page (YAML header) + * `{{ page.description }}` - The description of the page (YAML header) + * `{{ page.author }}` - The author of the page (YAML header) + * `{{ page.time }}` - The timestamp derived from the `Date` header + * `{{ page.date }}` - The date of the page (YAML header) + * `{{ page.date_formatted }}` - The formatted date of the page + * `{{ page.raw_content }}` - The raw, not yet parsed contents of the page; + use Twigs `content` filter to get the parsed + contents of a page by passing its unique ID + (e.g. `{{ "sub/page"|content }}`) + * `{{ page.meta }}`- The meta values of the page +* `{{ prev_page }}` - The data of the previous page (relative to `current_page`) +* `{{ current_page }}` - The data of the current page +* `{{ next_page }}` - The data of the next page (relative to `current_page`) * `{{ is_front_page }}` - A boolean flag for the front page -Pages can be used like: +Pages can be used like the following: + + -
<ul class="nav">
-	{% for page in pages %}
-	<li><a href="{{ page.url }}">{{ page.title }}</a></li>
-	{% endfor %}
-</ul>
+You can use different templates for different content files by specifying the +`Template` meta header. Simply add e.g. `Template: blog-post` to a content file +and Pico will use the `blog-post.twig` file in your theme folder to render +the page. + +You don't have to create your own theme if Pico's default theme isn't +sufficient for you, you can use one of the great themes third-party developers +and designers created in the past. As with plugins, you can find themes in +[our Wiki][WikiThemes]. ### Plugins -See [http://pico.dev7studios.com/plugins](http://picocms.org/plugins) +#### Plugins for users + +Officially tested plugins can be found at http://picocms.org/plugins.html, but +there are many awesome third-party plugins out there! A good start point for +discovery is [our Wiki][WikiPlugins]. + +Pico makes it very easy for you to add new features to your website. Simply +upload the files of the plugin to the `plugins/` directory and you're done. +Depending on the plugin you've installed, you may have to go through some more +steps (e.g. specifying config variables), the plugin docs or `README` file will +explain what to do. + +Plugins which were written to work with Pico 1.0 can be enabled and disabled +through your `config/config.php`. If you want to e.g. disable the `PicoExcerpt` +plugin, add the following line to your `config/config.php`: +`$config['PicoExcerpt.enabled'] = false;`. To force the plugin to be enabled +replace `false` with `true`. + +#### Plugins for developers + +You're a plugin developer? We love you guys! You can find tons of information +about how to develop plugins at http://picocms.org/plugin-dev.html. If you've +developed a plugin for Pico 0.9 or older, you probably want to upgrade it +to the brand new plugin system introduced with Pico 1.0. Please refer to the +[upgrade section of the docs][PluginUpgrade]. + +## Config + +You can override the default Pico settings (and add your own custom settings) +by editing `config/config.php` in the Pico directory. For a brief overview of +the available settings and their defaults see `config/config.php.template`. To +override a setting, copy `config/config.php.template` to `config/config.php`, +uncomment the setting and set your custom value. + +### URL Rewriting + +Pico's default URLs (e.g. %base_url%/?sub/page) already are very user-friendly. +Additionally, Pico offers you a URL rewrite feature to make URLs even more +user-friendly (e.g. %base_url%/sub/page). + +If you're using the Apache web server, URL rewriting probably already is +enabled - try it yourself, click on the [second URL](%base_url%/sub/page). If +you get an error message from your web server, please make sure to enable the +[`mod_rewrite` module][ModRewrite]. Assuming the second URL works, but Pico +still shows no rewritten URLs, force URL rewriting by setting +`$config['rewrite_url'] = true;` in your `config/config.php`. + +If you're using Nginx, you can use the following configuration to enable +URL rewriting. Don't forget to adjust the path (`/pico/`; line `1` and `4`) +to match your installation directory. You can then enable URL rewriting by +setting `$config['rewrite_url'] = true;` in your `config/config.php`. -### Config + location /pico/ { + index index.php; + try_files $uri $uri/ /pico/?$uri&$args; + } -You can override the default Pico settings (and add your own custom settings) by editing `config/config.php` in the Pico directory. -The `config/config.php.template` lists all of the settings and their defaults. To override a setting simply copy -`config/config.php.template` to `config/config.php`, uncomment the setting and set your custom value. +## Documentation -### Documentation +For more help have a look at the Pico documentation at http://picocms.org/docs. -For more help have a look at the Pico documentation at [http://picocms.org/docs](http://picocms.org/docs) +[Markdown]: http://daringfireball.net/projects/markdown/syntax +[Twig]: http://twig.sensiolabs.org/documentation +[WikiThemes]: https://github.com/picocms/Pico/wiki/Pico-Themes +[WikiPlugins]: https://github.com/picocms/Pico/wiki/Pico-Plugins +[PluginUpgrade]: http://picocms.org/plugin-dev.html#upgrade +[ModRewrite]: https://httpd.apache.org/docs/current/mod/mod_rewrite.html diff --git a/content-sample/sub/index.md b/content-sample/sub/index.md index 517e191cf..42edf3f01 100644 --- a/content-sample/sub/index.md +++ b/content-sample/sub/index.md @@ -1,10 +1,10 @@ -/* +--- Title: Sub Page Index -*/ +--- ## This is a Sub Page Index -This is index.md in the "sub" folder. +This is `index.md` in the `sub` folder. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec ultricies tristique nulla et mattis. Phasellus id massa eget nisl congue blandit sit amet id ligula. Praesent et nulla eu augue tempus sagittis. Mauris faucibus nibh et nibh cursus in vestibulum sapien egestas. Curabitur ut lectus tortor. Sed ipsum eros, egestas ut eleifend non, elementum vitae eros. Mauris felis diam, pellentesque vel lacinia ac, dictum a nunc. Mauris mattis nunc sed mi sagittis et facilisis tortor volutpat. Etiam tincidunt urna mattis erat placerat placerat ac eu tellus. Ut nec velit id nisl tincidunt vehicula id a metus. Pellentesque erat neque, faucibus id ultricies vel, mattis in ante. Donec lobortis, mauris id congue scelerisque, diam nisl accumsan orci, condimentum porta est magna vel arcu. Curabitur varius ante dui. Vivamus sit amet ante ac diam ullamcorper sodales sed a odio. diff --git a/content-sample/sub/page.md b/content-sample/sub/page.md index 2e2aebd51..95d67bdfc 100644 --- a/content-sample/sub/page.md +++ b/content-sample/sub/page.md @@ -1,10 +1,10 @@ -/* +--- Title: Sub Page -*/ +--- ## This is a Sub Page -This is page.md in the "sub" folder. +This is `page.md` in the `sub` folder. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec ultricies tristique nulla et mattis. Phasellus id massa eget nisl congue blandit sit amet id ligula. Praesent et nulla eu augue tempus sagittis. Mauris faucibus nibh et nibh cursus in vestibulum sapien egestas. Curabitur ut lectus tortor. Sed ipsum eros, egestas ut eleifend non, elementum vitae eros. Mauris felis diam, pellentesque vel lacinia ac, dictum a nunc. Mauris mattis nunc sed mi sagittis et facilisis tortor volutpat. Etiam tincidunt urna mattis erat placerat placerat ac eu tellus. Ut nec velit id nisl tincidunt vehicula id a metus. Pellentesque erat neque, faucibus id ultricies vel, mattis in ante. Donec lobortis, mauris id congue scelerisque, diam nisl accumsan orci, condimentum porta est magna vel arcu. Curabitur varius ante dui. Vivamus sit amet ante ac diam ullamcorper sodales sed a odio. diff --git a/index.php b/index.php index b69d4acdd..40b811a0d 100644 --- a/index.php +++ b/index.php @@ -1,15 +1,17 @@ setConfig(array()); -require_once(VENDOR_DIR . 'autoload.php'); -require_once(LIB_DIR . 'pico.php'); -$pico = new Pico(); +// run application +echo $pico->run(); diff --git a/lib/AbstractPicoPlugin.php b/lib/AbstractPicoPlugin.php new file mode 100644 index 000000000..55660d2d3 --- /dev/null +++ b/lib/AbstractPicoPlugin.php @@ -0,0 +1,255 @@ +pico = $pico; + } + + /** + * @see PicoPluginInterface::handleEvent() + */ + public function handleEvent($eventName, array $params) + { + // plugins can be enabled/disabled using the config + if ($eventName === 'onConfigLoaded') { + $pluginEnabled = $this->getConfig(get_called_class() . '.enabled'); + if ($pluginEnabled !== null) { + $this->setEnabled($pluginEnabled); + } else { + $pluginConfig = $this->getConfig(get_called_class()); + if (is_array($pluginConfig) && isset($pluginConfig['enabled'])) { + $this->setEnabled($pluginConfig['enabled']); + } + } + } + + if ($this->isEnabled() || ($eventName === 'onPluginsLoaded')) { + if (method_exists($this, $eventName)) { + call_user_func_array(array($this, $eventName), $params); + } + } + } + + /** + * @see PicoPluginInterface::setEnabled() + */ + public function setEnabled($enabled, $recursive = true, $auto = false) + { + $this->statusChanged = (!$this->statusChanged) ? !$auto : true; + $this->enabled = (bool) $enabled; + + if ($enabled) { + $this->checkDependencies($recursive); + } else { + $this->checkDependants($recursive); + } + } + + /** + * @see PicoPluginInterface::isEnabled() + */ + public function isEnabled() + { + return $this->enabled; + } + + /** + * @see PicoPluginInterface::isStatusChanged() + */ + public function isStatusChanged() + { + return $this->statusChanged; + } + + /** + * @see PicoPluginInterface::getPico() + */ + public function getPico() + { + return $this->pico; + } + + /** + * Passes all not satisfiable method calls to Pico + * + * @see Pico + * @param string $methodName name of the method to call + * @param array $params parameters to pass + * @return mixed return value of the called method + */ + public function __call($methodName, array $params) + { + if (method_exists($this->getPico(), $methodName)) { + return call_user_func_array(array($this->getPico(), $methodName), $params); + } + + throw new BadMethodCallException( + 'Call to undefined method ' . get_class($this->getPico()) . '::' . $methodName . '() ' + . 'through ' . get_called_class() . '::__call()' + ); + } + + /** + * Enables all plugins which this plugin depends on + * + * @see PicoPluginInterface::getDependencies() + * @param boolean $recursive enable required plugins automatically + * @return void + * @throws RuntimeException thrown when a dependency fails + */ + protected function checkDependencies($recursive) + { + foreach ($this->getDependencies() as $pluginName) { + try { + $plugin = $this->getPlugin($pluginName); + } catch (RuntimeException $e) { + throw new RuntimeException( + "Unable to enable plugin '" . get_called_class() . "':" + . "Required plugin '" . $pluginName . "' not found" + ); + } + + // plugins which don't implement PicoPluginInterface are always enabled + if (is_a($plugin, 'PicoPluginInterface') && !$plugin->isEnabled()) { + if ($recursive) { + if (!$plugin->isStatusChanged()) { + $plugin->setEnabled(true, true, true); + } else { + throw new RuntimeException( + "Unable to enable plugin '" . get_called_class() . "':" + . "Required plugin '" . $pluginName . "' was disabled manually" + ); + } + } else { + throw new RuntimeException( + "Unable to enable plugin '" . get_called_class() . "':" + . "Required plugin '" . $pluginName . "' is disabled" + ); + } + } + } + } + + /** + * @see PicoPluginInterface::getDependencies() + */ + public function getDependencies() + { + return (array) $this->dependsOn; + } + + /** + * Disables all plugins which depend on this plugin + * + * @see PicoPluginInterface::getDependants() + * @param boolean $recursive disabled dependant plugins automatically + * @return void + * @throws RuntimeException thrown when a dependency fails + */ + protected function checkDependants($recursive) + { + $dependants = $this->getDependants(); + if (!empty($dependants)) { + if ($recursive) { + foreach ($this->getDependants() as $pluginName => $plugin) { + if ($plugin->isEnabled()) { + if (!$plugin->isStatusChanged()) { + $plugin->setEnabled(false, true, true); + } else { + throw new RuntimeException( + "Unable to disable plugin '" . get_called_class() . "': " + . "Required by manually enabled plugin '" . $pluginName . "'" + ); + } + } + } + } else { + $dependantsList = 'plugin' . ((count($dependants) > 1) ? 's' : '') . ' '; + $dependantsList .= "'" . implode("', '", array_keys($dependants)) . "'"; + throw new RuntimeException( + "Unable to disable plugin '" . get_called_class() . "': " + . "Required by " . $dependantsList + ); + } + } + } + + /** + * @see PicoPluginInterface::getDependants() + */ + public function getDependants() + { + if ($this->dependants === null) { + $this->dependants = array(); + foreach ($this->getPlugins() as $pluginName => $plugin) { + // only plugins which implement PicoPluginInterface support dependencies + if (is_a($plugin, 'PicoPluginInterface')) { + $dependencies = $plugin->getDependencies(); + if (in_array(get_called_class(), $dependencies)) { + $this->dependants[$pluginName] = $plugin; + } + } + } + } + + return $this->dependants; + } +} diff --git a/lib/Pico.php b/lib/Pico.php new file mode 100644 index 000000000..91a6c27b0 --- /dev/null +++ b/lib/Pico.php @@ -0,0 +1,1276 @@ + for more info. + * + * @author Gilbert Pellegrom + * @author Daniel Rudolf + * @link + * @license The MIT License + * @version 1.0 + */ +class Pico +{ + /** + * Sort files in alphabetical ascending order + * + * @see Pico::getFiles() + * @var int + */ + const SORT_ASC = 0; + + /** + * Sort files in alphabetical descending order + * + * @see Pico::getFiles() + * @var int + */ + const SORT_DESC = 1; + + /** + * Don't sort files + * + * @see Pico::getFiles() + * @var int + */ + const SORT_NONE = 2; + + /** + * Root directory of this Pico instance + * + * @see Pico::getRootDir() + * @var string + */ + protected $rootDir; + + /** + * Config directory of this Pico instance + * + * @see Pico::getConfigDir() + * @var string + */ + protected $configDir; + + /** + * Plugins directory of this Pico instance + * + * @see Pico::getPluginsDir() + * @var string + */ + protected $pluginsDir; + + /** + * Themes directory of this Pico instance + * + * @see Pico::getThemesDir() + * @var string + */ + protected $themesDir; + + /** + * Boolean indicating whether Pico started processing yet + * + * @var boolean + */ + protected $locked = false; + + /** + * List of loaded plugins + * + * @see Pico::getPlugins() + * @var object[]|null + */ + protected $plugins; + + /** + * Current configuration of this Pico instance + * + * @see Pico::getConfig() + * @var mixed[]|null + */ + protected $config; + + /** + * Part of the URL describing the requested contents + * + * @see Pico::getRequestUrl() + * @var string|null + */ + protected $requestUrl; + + /** + * Absolute path to the content file being served + * + * @see Pico::getRequestFile() + * @var string|null + */ + protected $requestFile; + + /** + * Raw, not yet parsed contents to serve + * + * @see Pico::getRawContent() + * @var string|null + */ + protected $rawContent; + + /** + * Meta data of the page to serve + * + * @see Pico::getFileMeta() + * @var string[]|null + */ + protected $meta; + + /** + * Parsed content being served + * + * @see Pico::getFileContent() + * @var string|null + */ + protected $content; + + /** + * List of known pages + * + * @see Pico::getPages() + * @var array[]|null + */ + protected $pages; + + /** + * Data of the page being served + * + * @see Pico::getCurrentPage() + * @var array|null + */ + protected $currentPage; + + /** + * Data of the previous page relative to the page being served + * + * @see Pico::getPreviousPage() + * @var array|null + */ + protected $previousPage; + + /** + * Data of the next page relative to the page being served + * + * @see Pico::getNextPage() + * @var array|null + */ + protected $nextPage; + + /** + * Twig instance used for template parsing + * + * @see Pico::getTwig() + * @var Twig_Environment|null + */ + protected $twig; + + /** + * Variables passed to the twig template + * + * @see Pico::getTwigVariables + * @var mixed[]|null + */ + protected $twigVariables; + + /** + * Constructs a new Pico instance + * + * To carry out all the processing in Pico, call {@link Pico::run()}. + * + * @param string $rootDir root directory of this Pico instance + * @param string $configDir config directory of this Pico instance + * @param string $pluginsDir plugins directory of this Pico instance + * @param string $themesDir themes directory of this Pico instance + */ + public function __construct($rootDir, $configDir, $pluginsDir, $themesDir) + { + $this->rootDir = rtrim($rootDir, '/') . '/'; + $this->configDir = $this->getAbsolutePath($configDir); + $this->pluginsDir = $this->getAbsolutePath($pluginsDir); + $this->themesDir = $this->getAbsolutePath($themesDir); + } + + /** + * Returns the root directory of this Pico instance + * + * @return string root directory path + */ + public function getRootDir() + { + return $this->rootDir; + } + + /** + * Returns the config directory of this Pico instance + * + * @return string config directory path + */ + public function getConfigDir() + { + return $this->configDir; + } + + /** + * Returns the plugins directory of this Pico instance + * + * @return string plugins directory path + */ + public function getPluginsDir() + { + return $this->pluginsDir; + } + + /** + * Returns the themes directory of this Pico instance + * + * @return string themes directory path + */ + public function getThemesDir() + { + return $this->themesDir; + } + + /** + * Runs this Pico instance + * + * Loads plugins, evaluates the config file, does URL routing, parses + * meta headers, processes Markdown, does Twig processing and returns + * the rendered contents. + * + * @return string rendered Pico contents + */ + public function run() + { + // lock Pico + $this->locked = true; + + // load plugins + $this->loadPlugins(); + $this->triggerEvent('onPluginsLoaded', array(&$this->plugins)); + + // load config + $this->loadConfig(); + $this->triggerEvent('onConfigLoaded', array(&$this->config)); + + // evaluate request url + $this->evaluateRequestUrl(); + $this->triggerEvent('onRequestUrl', array(&$this->requestUrl)); + + // discover requested file + $this->discoverRequestFile(); + $this->triggerEvent('onRequestFile', array(&$this->requestFile)); + + // load raw file content + $this->triggerEvent('onContentLoading', array(&$this->requestFile)); + + if (file_exists($this->requestFile)) { + $this->rawContent = $this->loadFileContent($this->requestFile); + } else { + $this->triggerEvent('on404ContentLoading', array(&$this->requestFile)); + + header($_SERVER['SERVER_PROTOCOL'] . ' 404 Not Found'); + $this->rawContent = $this->load404Content($this->requestFile); + + $this->triggerEvent('on404ContentLoaded', array(&$this->rawContent)); + } + + $this->triggerEvent('onContentLoaded', array(&$this->rawContent)); + + // parse file meta + $headers = $this->getMetaHeaders(); + + $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->meta); + $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->sortPages(); + $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($this->getThemesDir() . $this->getConfig('theme') . '/' . $templateName . '.twig')) { + $templateName .= '.twig'; + } else { + $templateName .= '.html'; + } + + $this->triggerEvent('onPageRendering', array(&$this->twig, &$this->twigVariables, &$templateName)); + + $output = $this->twig->render($templateName, $this->twigVariables); + $this->triggerEvent('onPageRendered', array(&$output)); + + return $output; + } + + /** + * Loads plugins from Pico::$pluginsDir in alphabetical order + * + * Plugin files may be prefixed by a number (e.g. 00-PicoDeprecated.php) + * to indicate their processing order. You MUST NOT use prefixes between + * 00 and 19 (reserved for built-in plugins). + * + * @see Pico::getPlugin() + * @see Pico::getPlugins() + * @return void + * @throws RuntimeException thrown when a plugin couldn't be loaded + */ + protected function loadPlugins() + { + $this->plugins = array(); + $pluginFiles = $this->getFiles($this->getPluginsDir(), '.php'); + foreach ($pluginFiles as $pluginFile) { + require_once($pluginFile); + + $className = preg_replace('/^[0-9]+-/', '', basename($pluginFile, '.php')); + if (class_exists($className)) { + // class name and file name can differ regarding case sensitivity + $plugin = new $className($this); + $className = get_class($plugin); + + $this->plugins[$className] = $plugin; + } else { + // TODO: breaks backward compatibility + //throw new RuntimeException("Unable to load plugin '".$className."'"); + } + } + } + + /** + * Returns the instance of a named plugin + * + * Plugins SHOULD implement {@link PicoPluginInterface}, but you MUST NOT + * rely on it. For more information see {@link PicoPluginInterface}. + * + * @see Pico::loadPlugins() + * @see Pico::getPlugins() + * @param string $pluginName name of the plugin + * @return object instance of the plugin + * @throws RuntimeException thrown when the plugin wasn't found + */ + public function getPlugin($pluginName) + { + if (isset($this->plugins[$pluginName])) { + return $this->plugins[$pluginName]; + } + + throw new RuntimeException("Missing plugin '" . $pluginName . "'"); + } + + /** + * Returns all loaded plugins + * + * @see Pico::loadPlugins() + * @see Pico::getPlugin() + * @return object[]|null + */ + public function getPlugins() + { + return $this->plugins; + } + + /** + * Loads the config.php from Pico::$configDir + * + * @see Pico::setConfig() + * @see Pico::getConfig() + * @return void + */ + protected function loadConfig() + { + $config = null; + $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' => null, + 'content_ext' => '.md', + 'timezone' => '' + ); + + $configFile = $this->getConfigDir() . 'config.php'; + if (file_exists($configFile)) { + require $configFile; + } + + $this->config = is_array($this->config) ? $this->config : array(); + $this->config += is_array($config) ? $config + $defaultConfig : $defaultConfig; + + if (empty($this->config['base_url'])) { + $this->config['base_url'] = $this->getBaseUrl(); + } else { + $this->config['base_url'] = rtrim($this->config['base_url'], '/') . '/'; + } + + if (empty($this->config['content_dir'])) { + // try to guess the content directory + if (is_dir($this->getRootDir() . 'content')) { + $this->config['content_dir'] = $this->getRootDir() . 'content/'; + } else { + $this->config['content_dir'] = $this->getRootDir() . 'content-sample/'; + } + } else { + $this->config['content_dir'] = $this->getAbsolutePath($this->config['content_dir']); + } + + if (empty($this->config['timezone'])) { + // 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 + $this->config['timezone'] = date_default_timezone_get(); + } + date_default_timezone_set($this->config['timezone']); + } + + /** + * Sets Pico's config before calling Pico::run() + * + * This method allows you to modify Pico's config without creating a + * {@path "config/config.php"} or changing some of its variables before + * Pico starts processing. + * + * You can call this method between {@link Pico::__construct()} and + * {@link Pico::run()} only. Options set with this method cannot be + * overwritten by {@path "config/config.php"}. + * + * @see Pico::loadConfig() + * @see Pico::getConfig() + * @param mixed[] $config array with config variables + * @return void + * @throws RuntimeException thrown if Pico already started processing + */ + public function setConfig(array $config) + { + if ($this->locked) { + throw new RuntimeException("You cannot modify Pico's config after processing has started"); + } + + $this->config = $config; + } + + /** + * Returns either the value of the specified config variable or + * the config array + * + * @see Pico::setConfig() + * @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 will 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` will be automatically + * replaced accordingly. + * + * @see Pico::getRequestUrl() + * @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 = isset($_SERVER['QUERY_STRING']) ? $_SERVER['QUERY_STRING'] : ''; + if (($pathComponentLength = strpos($pathComponent, '&')) !== false) { + $pathComponent = substr($pathComponent, 0, $pathComponentLength); + } + $this->requestUrl = (strpos($pathComponent, '=') === false) ? urldecode($pathComponent) : ''; + } + + /** + * Returns the URL where a user requested the page + * + * @see Pico::evaluateRequestUrl() + * @return string|null request URL + */ + public function getRequestUrl() + { + return $this->requestUrl; + } + + /** + * Uses the request URL to discover the content file to serve + * + * @see Pico::getRequestFile() + * @return void + */ + protected function discoverRequestFile() + { + if (empty($this->requestUrl)) { + $this->requestFile = $this->getConfig('content_dir') . 'index' . $this->getConfig('content_ext'); + } else { + // prevent content_dir breakouts using malicious request URLs + // we don't use realpath() here because we neither want to check for file existance + // nor prohibit symlinks which intentionally point to somewhere outside the content_dir + // it is STRONGLY RECOMMENDED to use open_basedir - always, not just with Pico! + $requestUrl = str_replace('\\', '/', $this->requestUrl); + $requestUrlParts = explode('/', $requestUrl); + + $requestFileParts = array(); + foreach ($requestUrlParts as $requestUrlPart) { + if (($requestUrlPart === '') || ($requestUrlPart === '.')) { + continue; + } elseif ($requestUrlPart === '..') { + array_pop($requestFileParts); + continue; + } + + $requestFileParts[] = $requestUrlPart; + } + + if (empty($requestFileParts)) { + $this->requestFile = $this->getConfig('content_dir') . 'index' . $this->getConfig('content_ext'); + return; + } + + // discover the content file to serve + // Note: $requestFileParts neither contains a trailing nor a leading slash + $this->requestFile = $this->getConfig('content_dir') . implode('/', $requestFileParts); + 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 absolute path to the content file to serve + * + * @see Pico::discoverRequestFile() + * @return string|null file path + */ + public function getRequestFile() + { + return $this->requestFile; + } + + /** + * Returns the raw contents of a file + * + * @see Pico::getRawContent() + * @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 first found 404 file when traversing + * up from the directory the requested file is in + * + * @see Pico::getRawContent() + * @param string $file path to requested (but not existing) file + * @return string raw contents of the 404 file + * @throws RuntimeException thrown when no suitable 404 file is found + */ + public function load404Content($file) + { + $errorFileDir = substr($file, strlen($this->getConfig('content_dir'))); + do { + $errorFileDir = dirname($errorFileDir); + $errorFile = $errorFileDir . '/404' . $this->getConfig('content_ext'); + } while (!file_exists($this->getConfig('content_dir') . $errorFile) && ($errorFileDir !== '.')); + + if (!file_exists($this->getConfig('content_dir') . $errorFile)) { + $errorFile = ($errorFileDir === '.') ? '404' . $this->getConfig('content_ext') : $errorFile; + throw new RuntimeException('Required "' . $errorFile . '" not found'); + } + + return $this->loadFileContent($this->getConfig('content_dir') . $errorFile); + } + + /** + * Returns the raw contents, either of the requested or the 404 file + * + * @see Pico::loadFileContent() + * @see Pico::load404Content() + * @return string|null 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 string[] known meta headers; the array value specifies the + * YAML key to search for, the array key is later used to access the + * found value + */ + public function getMetaHeaders() + { + $headers = array( + 'title' => 'Title', + 'description' => 'Description', + 'author' => 'Author', + 'date' => 'Date', + 'robots' => 'Robots', + 'template' => 'Template' + ); + + $this->triggerEvent('onMetaHeaders', array(&$headers)); + return $headers; + } + + /** + * Parses the file meta from raw file contents + * + * 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, keys are lowered. + * If you're a plugin developer, you MUST register new headers during the + * `onMetaHeaders` event first. The implicit availability of headers is + * for users and pure (!) theme developers ONLY. + * + * @see Pico::getFileMeta() + * @see + * @param string $rawContent the raw file contents + * @param string[] $headers known meta headers + * @return array parsed meta data + */ + public function parseFileMeta($rawContent, array $headers) + { + $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(); + $meta = $yamlParser->parse($rawMetaMatches[3]); + $meta = array_change_key_case($meta, CASE_LOWER); + + foreach ($headers as $fieldId => $fieldName) { + $fieldName = strtolower($fieldName); + if (isset($meta[$fieldName])) { + // rename field (e.g. remove whitespaces) + if ($fieldId != $fieldName) { + $meta[$fieldId] = $meta[$fieldName]; + unset($meta[$fieldName]); + } + } else { + // guarantee array key existance + $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 { + // guarantee array key existance + 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|null 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% + * + * @see Pico::parseFileContent() + * @see Pico::getFileContent() + * @param string $rawContent raw contents of a page + * @param array $meta meta data to use for %meta.*% replacement + * @return string contents prepared for parsing + */ + public function prepareFileContent($rawContent, array $meta) + { + // 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 URL 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($this->getThemesDir()) . '/' . $this->getConfig('theme'); + $content = str_replace('%theme_url%', $themeUrl, $content); + + // replace %meta.*% + if (!empty($meta)) { + $metaKeys = $metaValues = array(); + foreach ($meta as $metaKey => $metaValue) { + if (is_scalar($metaValue) || ($metaValue === null)) { + $metaKeys[] = '%meta.' . $metaKey . '%'; + $metaValues[] = strval($metaValue); + } + } + $content = str_replace($metaKeys, $metaValues, $content); + } + + return $content; + } + + /** + * Parses the contents of a page using ParsedownExtra + * + * @see Pico::prepareFileContent() + * @see Pico::getFileContent() + * @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::prepareFileContent() + * @see Pico::parseFileContent() + * @return string|null parsed contents + */ + public function getFileContent() + { + return $this->content; + } + + /** + * Reads the data of all pages known to Pico + * + * The page data will be an array containing the following values: + *
+     * +----------------+--------+------------------------------------------+
+     * | Array key      | Type   | Description                              |
+     * +----------------+--------+------------------------------------------+
+     * | id             | string | relative path to the content file        |
+     * | url            | string | URL to the page                          |
+     * | title          | string | title of the page (YAML header)          |
+     * | description    | string | description of the page (YAML header)    |
+     * | author         | string | author of the page (YAML header)         |
+     * | time           | string | timestamp derived from the Date header   |
+     * | date           | string | date of the page (YAML header)           |
+     * | date_formatted | string | formatted date of the page               |
+     * | raw_content    | string | raw, not yet parsed contents of the page |
+     * | meta           | string | parsed meta data of the page             |
+     * +----------------+--------+------------------------------------------+
+     * 
+ * + * @see Pico::sortPages() + * @see Pico::getPages() + * @return void + */ + protected function readPages() + { + $this->pages = array(); + $files = $this->getFiles($this->getConfig('content_dir'), $this->getConfig('content_ext'), Pico::SORT_NONE); + 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'))); + + // drop inaccessible pages (e.g. drop "sub.md" if "sub/index.md" exists) + $conflictFile = $this->getConfig('content_dir') . $id . '/index' . $this->getConfig('content_ext'); + if (in_array($conflictFile, $files, true)) { + continue; + } + + $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)); + + $this->pages[$id] = $page; + } + } + + /** + * Sorts all pages known to Pico + * + * @see Pico::readPages() + * @see Pico::getPages() + * @return void + */ + protected function sortPages() + { + // sort pages + $order = $this->getConfig('pages_order'); + $alphaSortClosure = function ($a, $b) use ($order) { + $aSortKey = (basename($a['id']) === 'index') ? dirname($a['id']) : $a['id']; + $bSortKey = (basename($b['id']) === 'index') ? dirname($b['id']) : $b['id']; + + $cmp = strcmp($aSortKey, $bSortKey); + return $cmp * (($order == 'desc') ? -1 : 1); + }; + + if ($this->getConfig('pages_order_by') == 'date') { + // sort by date + uasort($this->pages, function ($a, $b) use ($alphaSortClosure, $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 alphabetical order + return $alphaSortClosure($a, $b); + } + + return $cmp * (($order == 'desc') ? 1 : -1); + }); + } else { + // sort alphabetically + uasort($this->pages, $alphaSortClosure); + } + } + + /** + * Returns the list of known pages + * + * @see Pico::readPages() + * @see Pico::sortPages() + * @return array|null 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 + * + * @see Pico::getCurrentPage() + * @see Pico::getPreviousPage() + * @see Pico::getNextPage() + * @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|null 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|null 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|null page data + */ + public function getNextPage() + { + return $this->nextPage; + } + + /** + * Registers the twig template engine + * + * @see Pico::getTwig() + * @return void + */ + protected function registerTwig() + { + $twigLoader = new Twig_Loader_Filesystem($this->getThemesDir() . $this->getConfig('theme')); + $this->twig = new Twig_Environment($twigLoader, $this->getConfig('twig_config')); + $this->twig->addExtension(new Twig_Extension_Debug()); + + // register link filter + $this->twig->addFilter(new Twig_SimpleFilter('link', array($this, 'getPageUrl'))); + + // register content filter + $pico = $this; + $pages = &$this->pages; + $this->twig->addFilter(new Twig_SimpleFilter('content', function ($pageId) use ($pico, &$pages) { + if (isset($pages[$pageId])) { + $pageData = &$pages[$pageId]; + if (!isset($pageData['content'])) { + $pageData['content'] = $pico->prepareFileContent($pageData['raw_content'], $pageData['meta']); + $pageData['content'] = $pico->parseFileContent($pageData['content']); + } + return $pageData['content']; + } + return ''; + })); + } + + /** + * Returns the twig template engine + * + * @see Pico::registerTwig() + * @return Twig_Environment|null 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 mixed[] template variables + */ + protected function getTwigVariables() + { + $frontPage = $this->getConfig('content_dir') . 'index' . $this->getConfig('content_ext'); + return array( + 'config' => $this->getConfig(), + 'base_dir' => rtrim($this->getRootDir(), '/'), + 'base_url' => rtrim($this->getBaseUrl(), '/'), + 'theme_dir' => $this->getThemesDir() . $this->getConfig('theme'), + 'theme_url' => $this->getBaseUrl() . basename($this->getThemesDir()) . '/' . $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 + */ + public function getBaseUrl() + { + $baseUrl = $this->getConfig('base_url'); + if (!empty($baseUrl)) { + return $baseUrl; + } + + 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'; + } + + $this->config['base_url'] = + $protocol . "://" . $_SERVER['HTTP_HOST'] + . dirname($_SERVER['SCRIPT_NAME']) . '/'; + + return $this->getConfig('base_url'); + } + + /** + * Returns true if URL rewriting is enabled + * + * @return boolean true if URL rewriting is enabled, false otherwise + */ + public function isUrlRewritingEnabled() + { + 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 + * + * @param string $directory start directory + * @param string $fileExtension return files with the given file extension + * only (optional) + * @param int $order specify whether and how files should be + * sorted; use Pico::SORT_ASC for a alphabetical ascending order (this + * is the default behaviour), Pico::SORT_DESC for a descending order + * or Pico::SORT_NONE to leave the result unsorted + * @return array list of found files + */ + protected function getFiles($directory, $fileExtension = '', $order = self::SORT_ASC) + { + $directory = rtrim($directory, '/'); + $result = array(); + + // scandir() reads files in alphabetical order + $files = scandir($directory, $order); + $fileExtensionLength = strlen($fileExtension); + if ($files !== false) { + foreach ($files as $file) { + // 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 (is_dir($directory . '/' . $file)) { + // get files recursively + $result = array_merge($result, $this->getFiles($directory . '/' . $file, $fileExtension, $order)); + } elseif (empty($fileExtension) || (substr($file, -$fileExtensionLength) === $fileExtension)) { + $result[] = $directory . '/' . $file; + } + } + } + + return $result; + } + + /** + * Makes a relative path absolute to Pico's root dir + * + * This method also guarantees a trailing slash. + * + * @param string $path relative or absolute path + * @return string absolute path + */ + protected function getAbsolutePath($path) + { + if (substr($path, 0, 1) !== '/') { + $path = $this->getRootDir() . $path; + } + return rtrim($path, '/') . '/'; + } + + /** + * Triggers events on plugins which implement PicoPluginInterface + * + * Deprecated events (as used by plugins not implementing + * {@link IPocPlugin}) are triggered by {@link PicoDeprecated}. + * + * @see PicoPluginInterface + * @see AbstractPicoPlugin + * @see DummyPlugin + * @param string $eventName name of the event to trigger + * @param array $params optional parameters to pass + * @return void + */ + protected function triggerEvent($eventName, array $params = array()) + { + if (!empty($this->plugins)) { + foreach ($this->plugins as $plugin) { + // only trigger events for plugins that implement PicoPluginInterface + // deprecated events (plugins for Pico 0.9 and older) will be + // triggered by the `PicoPluginDeprecated` plugin + if (is_a($plugin, 'PicoPluginInterface')) { + $plugin->handleEvent($eventName, $params); + } + } + } + } +} diff --git a/lib/PicoPluginInterface.php b/lib/PicoPluginInterface.php new file mode 100644 index 000000000..8ea3ab67c --- /dev/null +++ b/lib/PicoPluginInterface.php @@ -0,0 +1,102 @@ +load_plugins(); - $this->run_hooks('plugins_loaded'); - - // Load the settings - $settings = $this->get_config(); - $this->run_hooks('config_loaded', array(&$settings)); - - // 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'] : ''; - - // 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)); - - // Get the file path - if ($url) { - $file = $settings['content_dir'] . $url; - } else { - $file = $settings['content_dir'] . 'index'; - } - - // 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->run_hooks('after_load_content', array(&$file, &$content)); - - $meta = $this->read_file_meta($content); - $this->run_hooks('file_meta', array(&$meta)); - - $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 - - // 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); - } - $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, - ); - - $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 - */ - protected function load_plugins() - { - $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; - } - } - } - } - - /** - * Parses the content using Parsedown-extra - * - * @param string $content the raw txt content - * @return string $content the Markdown formatted content - */ - protected function parse_content($content) - { - $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); - - return $content; - } - - /** - * Parses the file meta from the txt file header - * - * @param string $content the raw txt content - * @return array $headers an array of meta values - */ - protected function read_file_meta($content) - { - $config = $this->config; - - $headers = array( - 'title' => 'Title', - 'description' => 'Description', - 'author' => 'Author', - 'date' => 'Date', - 'robots' => 'Robots', - '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']))); - } - - return $headers; - } - - /** - * Loads the config - * - * @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 - * - * @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 - */ - protected function get_pages($base_url, $order_by = 'alpha', $order = 'asc', $excerpt_length = 50) - { - $config = $this->config; - - $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); - } - } - } - } - - /** - * Helper function to work out the base URL - * - * @return string the base url - */ - protected function base_url() - { - $config = $this->config; - - if (isset($config['base_url']) && $config['base_url']) { - return $config['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'] != '') { - $protocol = 'https'; - } - - return $protocol; - } - - /** - * Helper function to recusively get all files in a directory - * - * @param string $directory start directory - * @param string $ext optional limit to file extensions - * @return array the matched files - */ - protected function get_files($directory, $ext = '') - { - $array_items = array(); - if ($files = scandir($directory)) { - foreach ($files as $file) { - if (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); - } - } - } - } - } - - return $array_items; - } - - /** - * Helper function to limit the words in a string - * - * @param string $string the given string - * @param int $word_limit the number of words to limit to - * @return string the limited string - */ - protected function limit_words($string, $word_limit) - { - $words = explode(' ', $string); - $excerpt = trim(implode(' ', array_splice($words, 0, $word_limit))); - if (count($words) > $word_limit) { - $excerpt .= '…'; - } - - return $excerpt; - } - -} diff --git a/plugins/00-PicoDeprecated.php b/plugins/00-PicoDeprecated.php new file mode 100644 index 000000000..53bfff2d0 --- /dev/null +++ b/plugins/00-PicoDeprecated.php @@ -0,0 +1,430 @@ + + * +---------------------+-----------------------------------------------------------+ + * | Event | ... triggers the deprecated event | + * +---------------------+-----------------------------------------------------------+ + * | onPluginsLoaded | plugins_loaded() | + * | onConfigLoaded | config_loaded($config) | + * | onRequestUrl | request_url($url) | + * | onContentLoading | before_load_content($file) | + * | onContentLoaded | after_load_content($file, $rawContent) | + * | on404ContentLoading | before_404_load_content($file) | + * | on404ContentLoaded | after_404_load_content($file, $rawContent) | + * | onMetaHeaders | before_read_file_meta($headers) | + * | onMetaParsed | file_meta($meta) | + * | onContentParsing | before_parse_content($rawContent) | + * | onContentParsed | after_parse_content($content) | + * | onContentParsed | content_parsed($content) | + * | onSinglePageLoaded | get_page_data($pages, $meta) | + * | onPagesLoaded | get_pages($pages, $currentPage, $previousPage, $nextPage) | + * | onTwigRegistration | before_twig_register() | + * | onPageRendering | before_render($twigVariables, $twig, $templateName) | + * | onPageRendered | after_render($output) | + * +---------------------+-----------------------------------------------------------+ + * + * + * Since Pico 1.0 the config is stored in {@path "config/config.php"}. This + * plugin tries to read {@path "config.php"} in Pico's root dir and overwrites + * all settings previously specified in {@path "config/config.php"}. + * + * @author Daniel Rudolf + * @link http://picocms.org + * @license http://opensource.org/licenses/MIT + * @version 1.0 + */ +class PicoDeprecated extends AbstractPicoPlugin +{ + /** + * This plugin is disabled by default + * + * @see AbstractPicoPlugin::$enabled + */ + protected $enabled = false; + + /** + * The requested file + * + * @see PicoDeprecated::getRequestFile() + * @var string|null + */ + protected $requestFile; + + /** + * Enables this plugin on demand and triggers the deprecated event + * plugins_loaded() + * + * @see DummyPlugin::onPluginsLoaded() + */ + public function onPluginsLoaded(&$plugins) + { + if (!empty($plugins)) { + foreach ($plugins as $plugin) { + if (!is_a($plugin, 'PicoPluginInterface')) { + // the plugin doesn't implement PicoPluginInterface; it uses deprecated events + // enable PicoDeprecated if it hasn't be explicitly enabled/disabled yet + if (!$this->isStatusChanged()) { + $this->setEnabled(true, true, true); + } + break; + } + } + } else { + // no plugins were found, so it actually isn't necessary to call deprecated events + // anyway, this plugin also ensures compatibility apart from events used by old plugins, + // so enable PicoDeprecated if it hasn't be explicitly enabled/disabled yet + if (!$this->isStatusChanged()) { + $this->setEnabled(true, true, true); + } + } + + if ($this->isEnabled()) { + $this->triggerEvent('plugins_loaded'); + } + } + + /** + * Triggers the deprecated event config_loaded($config) + * + * This method also defines deprecated constants, reads the `config.php` + * in Pico's root dir, enables the plugins {@link PicoParsePagesContent} + * and {@link PicoExcerpt} and makes `$config` globally accessible (the + * latter was removed with Pico 0.9 and was added again as deprecated + * feature with Pico 1.0) + * + * @see PicoDeprecated::defineConstants() + * @see PicoDeprecated::loadRootDirConfig() + * @see PicoDeprecated::enablePlugins() + * @see DummyPlugin::onConfigLoaded() + * @param mixed[] &$realConfig array of config variables + * @return void + */ + public function onConfigLoaded(&$realConfig) + { + global $config; + + $this->defineConstants(); + $this->loadRootDirConfig($realConfig); + $this->enablePlugins(); + $config = &$realConfig; + + $this->triggerEvent('config_loaded', array(&$realConfig)); + } + + /** + * Defines deprecated constants + * + * `ROOT_DIR`, `LIB_DIR`, `PLUGINS_DIR`, `THEMES_DIR` and `CONTENT_EXT` + * are deprecated since v1.0, `CONTENT_DIR` existed just in v0.9, + * `CONFIG_DIR` just for a short time between v0.9 and v1.0 and + * `CACHE_DIR` was dropped with v1.0 without a replacement. + * + * @see PicoDeprecated::onConfigLoaded() + * @return void + */ + protected function defineConstants() + { + if (!defined('ROOT_DIR')) { + define('ROOT_DIR', $this->getRootDir()); + } + if (!defined('CONFIG_DIR')) { + define('CONFIG_DIR', $this->getConfigDir()); + } + if (!defined('LIB_DIR')) { + $picoReflector = new ReflectionClass('Pico'); + define('LIB_DIR', dirname($picoReflector->getFileName() . '/')); + } + if (!defined('PLUGINS_DIR')) { + define('PLUGINS_DIR', $this->getPluginsDir()); + } + if (!defined('THEMES_DIR')) { + define('THEMES_DIR', $this->getThemesDir()); + } + if (!defined('CONTENT_DIR')) { + define('CONTENT_DIR', $this->getConfig('content_dir')); + } + if (!defined('CONTENT_EXT')) { + define('CONTENT_EXT', $this->getConfig('content_ext')); + } + } + + /** + * Read config.php in Pico's root dir + * + * @see PicoDeprecated::onConfigLoaded() + * @see Pico::loadConfig() + * @param mixed[] &$realConfig array of config variables + * @return void + */ + protected function loadRootDirConfig(&$realConfig) + { + if (file_exists($this->getRootDir() . 'config.php')) { + // config.php in Pico::$rootDir is deprecated; use Pico::$configDir instead + $config = null; + require($this->getRootDir() . 'config.php'); + + if (is_array($config)) { + $realConfig = $config + $realConfig; + } + } + } + + /** + * Enables the plugins PicoParsePagesContent and PicoExcerpt + * + * @see PicoParsePagesContent + * @see PicoExcerpt + * @return void + */ + protected function enablePlugins() + { + // enable PicoParsePagesContent and PicoExcerpt + // we can't enable them during onPluginsLoaded because we can't know + // if the user disabled us (PicoDeprecated) manually in the config + $plugins = $this->getPlugins(); + if (isset($plugins['PicoParsePagesContent'])) { + // parse all pages content if this plugin hasn't + // be explicitly enabled/disabled yet + if (!$plugins['PicoParsePagesContent']->isStatusChanged()) { + $plugins['PicoParsePagesContent']->setEnabled(true, true, true); + } + } + if (isset($plugins['PicoExcerpt'])) { + // enable excerpt plugin if it hasn't be explicitly enabled/disabled yet + if (!$plugins['PicoExcerpt']->isStatusChanged()) { + $plugins['PicoExcerpt']->setEnabled(true, true, true); + } + } + } + + /** + * Triggers the deprecated event request_url($url) + * + * @see DummyPlugin::onRequestUrl() + */ + public function onRequestUrl(&$url) + { + $this->triggerEvent('request_url', array(&$url)); + } + + /** + * Sets PicoDeprecated::$requestFile to trigger the deprecated + * events after_load_content() and after_404_load_content() + * + * @see PicoDeprecated::onContentLoaded() + * @see PicoDeprecated::on404ContentLoaded() + * @see DummyPlugin::onRequestFile() + */ + public function onRequestFile(&$file) + { + $this->requestFile = &$file; + } + + /** + * Triggers the deprecated before_load_content($file) + * + * @see DummyPlugin::onContentLoading() + */ + public function onContentLoading(&$file) + { + $this->triggerEvent('before_load_content', array(&$file)); + } + + /** + * Triggers the deprecated event after_load_content($file, $rawContent) + * + * @see DummyPlugin::onContentLoaded() + */ + public function onContentLoaded(&$rawContent) + { + $this->triggerEvent('after_load_content', array(&$this->requestFile, &$rawContent)); + } + + /** + * Triggers the deprecated before_404_load_content($file) + * + * @see DummyPlugin::on404ContentLoading() + */ + public function on404ContentLoading(&$file) + { + $this->triggerEvent('before_404_load_content', array(&$file)); + } + + /** + * Triggers the deprecated event after_404_load_content($file, $rawContent) + * + * @see DummyPlugin::on404ContentLoaded() + */ + public function on404ContentLoaded(&$rawContent) + { + $this->triggerEvent('after_404_load_content', array(&$this->requestFile, &$rawContent)); + } + + /** + * Triggers the deprecated event before_read_file_meta($headers) + * + * @see DummyPlugin::onMetaHeaders() + */ + public function onMetaHeaders(&$headers) + { + $this->triggerEvent('before_read_file_meta', array(&$headers)); + } + + /** + * Triggers the deprecated event file_meta($meta) + * + * @see DummyPlugin::onMetaParsed() + */ + public function onMetaParsed(&$meta) + { + $this->triggerEvent('file_meta', array(&$meta)); + } + + /** + * Triggers the deprecated event before_parse_content($rawContent) + * + * @see DummyPlugin::onContentParsing() + */ + public function onContentParsing(&$rawContent) + { + $this->triggerEvent('before_parse_content', array(&$rawContent)); + } + + /** + * Triggers the deprecated events after_parse_content($content) and + * content_parsed($content) + * + * @see DummyPlugin::onContentParsed() + */ + public function onContentParsed(&$content) + { + $this->triggerEvent('after_parse_content', array(&$content)); + + // deprecated since v0.8 + $this->triggerEvent('content_parsed', array(&$content)); + } + + /** + * Triggers the deprecated event get_page_data($pages, $meta) + * + * @see DummyPlugin::onSinglePageLoaded() + */ + public function onSinglePageLoaded(&$pageData) + { + $this->triggerEvent('get_page_data', array(&$pageData, $pageData['meta'])); + } + + /** + * Triggers the deprecated event + * get_pages($pages, $currentPage, $previousPage, $nextPage) + * + * Please note that the `get_pages()` event gets `$pages` passed without a + * array index. The index is rebuild later using either the `id` array key + * or is derived from the `url` array key. Duplicates are prevented by + * adding `~dup` when necessary. + * + * @see DummyPlugin::onPagesLoaded() + */ + public function onPagesLoaded(&$pages, &$currentPage, &$previousPage, &$nextPage) + { + // remove keys of pages array + $plainPages = array(); + foreach ($pages as &$pageData) { + $plainPages[] = &$pageData; + } + unset($pageData); + + $this->triggerEvent('get_pages', array(&$plainPages, &$currentPage, &$previousPage, &$nextPage)); + + // re-index pages array + $pages = array(); + foreach ($plainPages as &$pageData) { + if (!isset($pageData['id'])) { + $urlPrefixLength = strlen($this->getBaseUrl()) + intval(!$this->isUrlRewritingEnabled()); + $pageData['id'] = substr($pageData['url'], $urlPrefixLength); + } + + // prevent duplicates + $id = $pageData['id']; + for ($i = 1; isset($pages[$id]); $i++) { + $id = $pageData['id'] . '~dup' . $i; + } + + $pages[$id] = &$pageData; + } + } + + /** + * Triggers the deprecated event before_twig_register() + * + * @see DummyPlugin::onTwigRegistration() + */ + public function onTwigRegistration() + { + $this->triggerEvent('before_twig_register'); + } + + /** + * Triggers the deprecated event before_render($twigVariables, $twig, $templateName) + * + * Please note that the `before_render()` event gets `$templateName` passed + * without its file extension. The file extension is later added again. + * + * @see DummyPlugin::onPageRendering() + */ + public function onPageRendering(&$twig, &$twigVariables, &$templateName) + { + // template name contains file extension since Pico 1.0 + $fileExtension = ''; + if (($fileExtensionPos = strrpos($templateName, '.')) !== false) { + $fileExtension = substr($templateName, $fileExtensionPos); + $templateName = substr($templateName, 0, $fileExtensionPos); + } + + $this->triggerEvent('before_render', array(&$twigVariables, &$twig, &$templateName)); + + // add original file extension + $templateName = $templateName . $fileExtension; + } + + /** + * Triggers the deprecated event after_render($output) + * + * @see DummyPlugin::onPageRendered() + */ + public function onPageRendered(&$output) + { + $this->triggerEvent('after_render', array(&$output)); + } + + /** + * Triggers a deprecated event on all plugins + * + * Deprecated events are also triggered on plugins which implement + * {@link PicoPluginInterface}. Please note that the methods are called + * directly and not through {@link PicoPluginInterface::handleEvent()}. + * + * @param string $eventName event to trigger + * @param array $params parameters to pass + * @return void + */ + protected function triggerEvent($eventName, array $params = array()) + { + foreach ($this->getPlugins() as $plugin) { + if (method_exists($plugin, $eventName)) { + call_user_func_array(array($plugin, $eventName), $params); + } + } + } +} diff --git a/plugins/01-PicoParsePagesContent.php b/plugins/01-PicoParsePagesContent.php new file mode 100644 index 000000000..fd36f0adb --- /dev/null +++ b/plugins/01-PicoParsePagesContent.php @@ -0,0 +1,40 @@ +prepareFileContent($pageData['raw_content'], $pageData['meta']); + $pageData['content'] = $this->parseFileContent($pageData['content']); + } + } +} diff --git a/plugins/02-PicoExcerpt.php b/plugins/02-PicoExcerpt.php new file mode 100644 index 000000000..4ada382d3 --- /dev/null +++ b/plugins/02-PicoExcerpt.php @@ -0,0 +1,81 @@ +createExcerpt( + strip_tags($pageData['content']), + $this->getConfig('excerpt_length') + ); + } + } + + /** + * Helper function to create a excerpt of a string + * + * @param string $string the string to create a excerpt from + * @param int $wordLimit the maximum number of words the excerpt should be long + * @return string excerpt of $string + */ + protected function createExcerpt($string, $wordLimit) + { + $words = explode(' ', $string); + if (count($words) > $wordLimit) { + return trim(implode(' ', array_slice($words, 0, $wordLimit))) . '…'; + } + return $string; + } +} diff --git a/plugins/DummyPlugin.php b/plugins/DummyPlugin.php new file mode 100644 index 000000000..9ecd411d1 --- /dev/null +++ b/plugins/DummyPlugin.php @@ -0,0 +1,313 @@ + + * +----------------+--------+------------------------------------------+ + * | Array key | Type | Description | + * +----------------+--------+------------------------------------------+ + * | id | string | relative path to the content file | + * | url | string | URL to the page | + * | title | string | title of the page (YAML header) | + * | description | string | description of the page (YAML header) | + * | author | string | author of the page (YAML header) | + * | time | string | timestamp derived from the Date header | + * | date | string | date of the page (YAML header) | + * | date_formatted | string | formatted date of the page | + * | raw_content | string | raw, not yet parsed contents of the page | + * | meta | string | parsed meta data of the page | + * +----------------+--------+------------------------------------------+ + * + * + * @see DummyPlugin::onPagesLoaded() + * @param array &$pageData data of the loaded page + * @return void + */ + public function onSinglePageLoaded(&$pageData) + { + // your code + } + + /** + * Triggered after Pico has read all known pages + * + * See {@link DummyPlugin::onSinglePageLoaded()} for details about the + * structure of the page data. + * + * @see Pico::getPages() + * @see Pico::getCurrentPage() + * @see Pico::getPreviousPage() + * @see Pico::getNextPage() + * @param array &$pages data of all known pages + * @param array &$currentPage data of the page being served + * @param array &$previousPage data of the previous page + * @param array &$nextPage data of the next page + * @return void + */ + public function onPagesLoaded(&$pages, &$currentPage, &$previousPage, &$nextPage) + { + // your code + } + + /** + * Triggered before Pico registers the twig template engine + * + * @return void + */ + public function onTwigRegistration() + { + // your code + } + + /** + * Triggered before Pico renders the page + * + * @see Pico::getTwig() + * @see DummyPlugin::onPageRendered() + * @param Twig_Environment &$twig twig template engine + * @param mixed[] &$twigVariables template variables + * @param string &$templateName file name of the template + * @return void + */ + public function onPageRendering(&$twig, &$twigVariables, &$templateName) + { + // your code + } + + /** + * Triggered after Pico has rendered the page + * + * @param string &$output contents which will be sent to the user + * @return void + */ + public function onPageRendered(&$output) + { + // your code + } +} diff --git a/plugins/index.html b/plugins/index.html deleted file mode 100644 index e69de29bb..000000000 diff --git a/plugins/pico_plugin.php b/plugins/pico_plugin.php deleted file mode 100644 index 236b12edd..000000000 --- a/plugins/pico_plugin.php +++ /dev/null @@ -1,95 +0,0 @@ - diff --git a/themes/default/index.html b/themes/default/index.html deleted file mode 100644 index 9b5ed0af1..000000000 --- a/themes/default/index.html +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - {% if meta.title %}{{ meta.title }} | {% endif %}{{ site_title }} -{% if meta.description %} - -{% endif %}{% if meta.robots %} - -{% endif %} - - - - - - - - - - -
-
- {{ content }} -
-
- - - - - diff --git a/themes/default/index.twig b/themes/default/index.twig new file mode 100644 index 000000000..25d8b19ba --- /dev/null +++ b/themes/default/index.twig @@ -0,0 +1,49 @@ + + + + + + {% if meta.title %}{{ meta.title }} | {% endif %}{{ site_title }} + {% if meta.description %} + + {% endif %}{% if meta.robots %} + + {% endif %} + + + + + + + + + + +
+
+ {{ content }} +
+
+ + + + + diff --git a/themes/index.html b/themes/index.html deleted file mode 100644 index e69de29bb..000000000