diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a3fe95..d02dafe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,11 @@ Released: - ``` * [New] This is Picos first stable release! The Pico Community wants to thank all contributors and users which made this possible! +* [New] Introducing the `PicoTwigExtension` Twig extension * [New] New `markdown` filter for Twig to parse markdown strings; Note: If you want to parse the contents of a page, use the `content` filter instead +* [New] New `sort_by` filter to sort a array by a specified key or key path +* [New] New `map` filter to get the values of the given key or key path * [New] New PHP version check in `index.php` * [Changed] Improve documentation * [Changed] Improve table styling in default theme diff --git a/content-sample/index.md b/content-sample/index.md index 40abb8b..8d4474e 100644 --- a/content-sample/index.md +++ b/content-sample/index.md @@ -117,7 +117,7 @@ something like the following: This template will show a list of your articles, so you probably want to do something like this: ``` - {% for page in pages %} + {% for page in pages|sort_by("time")|reverse %} {% if page.id starts with "blog/" %}

{{ page.title }}

@@ -127,16 +127,10 @@ something like the following: {% 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 +4. 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. + to the navigation loop (`{% for page in pages|reverse %}...{% endfor %}`) + in your themes `index.twig`. ## Customization @@ -211,6 +205,17 @@ Pages can be used like the following: {% endfor %} +Additional to Twigs extensive list of filters, functions and tags, Pico also +provides some useful additional filters to make theming easier. You can parse +any Markdown string to HTML using the `markdown` filter. Arrays can be sorted +by one of its keys or a arbitrary deep sub-key using the `sort_by` filter +(e.g. `{% for page in pages|sort_by("meta:nav"|split(":")) %}...{% endfor %}` +iterates through all pages, ordered by the `nav` meta header; please note the +`"meta:nav"|split(":")` part of the example, which passes `['meta', 'nav']` to +the filter describing a key path). You can return all values of a given key or +key path of an array using the `map` filter (e.g. `{{ pages|map("title") }}` +returns all page titles). + 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 diff --git a/lib/Pico.php b/lib/Pico.php index 9f64b3e..334d792 100644 --- a/lib/Pico.php +++ b/lib/Pico.php @@ -1116,6 +1116,9 @@ class Pico /** * Registers the twig template engine * + * This method also registers Picos core Twig filters `link` and `content` + * as well as Picos {@link PicoTwigExtension} Twig extension. + * * @see Pico::getTwig() * @return void */ @@ -1124,23 +1127,15 @@ class Pico $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()); + $this->twig->addExtension(new PicoTwigExtension($this)); - $this->registerTwigFilter(); - } - - /** - * Registers Picos additional Twig filters - * - * @return void - */ - protected function registerTwigFilter() - { - $pico = $this; - - // link filter + // register link filter $this->twig->addFilter(new Twig_SimpleFilter('link', array($this, 'getPageUrl'))); - // content filter + // register content filter + // we pass the $pages array by reference to prevent multiple parser runs for the same page + // this is the reason why we can't register this filter as part of PicoTwigExtension + $pico = $this; $pages = &$this->pages; $this->twig->addFilter(new Twig_SimpleFilter('content', function ($page) use ($pico, &$pages) { if (isset($pages[$page])) { @@ -1153,15 +1148,6 @@ class Pico } return null; })); - - // markdown filter - $this->twig->addFilter(new Twig_SimpleFilter('markdown', function ($markdown) use ($pico) { - if ($pico->getParsedown() === null) { - throw new LogicException("Unable to parse file contents: Parsedown instance wasn't registered yet"); - } - - return $pico->getParsedown()->text($markdown); - })); } /** diff --git a/lib/PicoTwigExtension.php b/lib/PicoTwigExtension.php new file mode 100644 index 0000000..c249ff4 --- /dev/null +++ b/lib/PicoTwigExtension.php @@ -0,0 +1,238 @@ +pico = $pico; + } + + /** + * Returns the extensions instance of Pico + * + * @see Pico + * @return Pico the extensions instance of Pico + */ + public function getPico() + { + return $this->pico; + } + + /** + * Returns the name of the extension + * + * @see Twig_ExtensionInterface::getName() + * @return string the extension name + */ + public function getName() + { + return 'PicoTwigExtension'; + } + + /** + * Returns the Twig filters markdown, map and sort_by + * + * @see Twig_ExtensionInterface::getFilters() + * @return Twig_SimpleFilter[] array of Picos Twig filters + */ + public function getFilters() + { + return array( + 'markdown' => new Twig_SimpleFilter('markdown', array($this, 'markdownFilter')), + 'map' => new Twig_SimpleFilter('map', array($this, 'mapFilter')), + 'sort_by' => new Twig_SimpleFilter('sort_by', array($this, 'sortByFilter')), + ); + } + + /** + * Parses a markdown string to HTML + * + * This method is registered as the Twig `markdown` filter. You can use it + * to e.g. parse a meta variable (`{{ meta.description|markdown }}`). + * Don't use it to parse the contents of a page, use the `content` filter + * instead, what ensures the proper preparation of the contents. + * + * @param string $markdown markdown to parse + * @return string parsed HTML + */ + public function markdownFilter($markdown) + { + if ($this->getPico()->getParsedown() === null) { + throw new LogicException( + 'Unable to apply Twig "markdown" filter: ' + . 'Parsedown instance wasn\'t registered yet' + ); + } + + return $this->getPico()->getParsedown()->text($markdown); + } + + /** + * Returns a array with the values of the given key or key path + * + * This method is registered as the Twig `map` filter. You can use this + * filter to e.g. get all page titles (`{{ pages|map("title") }}`). + * + * @param array|Traversable $var variable to map + * @param mixed $mapKeyPath key to map; either a scalar or a + * array interpreted as key path (i.e. ['foo', 'bar'] will return all + * $item['foo']['bar'] values) + * @return array mapped values + */ + public function mapFilter($var, $mapKeyPath) + { + if (!is_array($var) && (!is_object($var) || !is_a($var, 'Traversable'))) { + throw new Twig_Error_Runtime(sprintf( + 'The map filter only works with arrays or "Traversable", got "%s"', + is_object($var) ? get_class($var) : gettype($var) + )); + } + + $result = array(); + foreach ($var as $key => $value) { + $mapValue = $this->getKeyOfVar($value, $mapKeyPath); + $result[$key] = ($mapValue !== null) ? $mapValue : $value; + } + return $result; + } + + /** + * Sorts an array by one of its keys or a arbitrary deep sub-key + * + * This method is registered as the Twig `sort_by` filter. You can use this + * filter to e.g. sort the pages array by a arbitrary meta value. Calling + * `{{ pages|sort_by("meta:nav"|split(":")) }}` returns all pages sorted by + * the meta value `nav`. Please note the `"meta:nav"|split(":")` part of + * the example. The sorting algorithm will never assume equality of two + * values, it will then fall back to the original order. The result is + * always sorted in ascending order, apply Twigs `reverse` filter to + * achieve a descending order. + * + * @param array|Traversable $var variable to sort + * @param mixed $sortKeyPath key to use for sorting; either + * a scalar or a array interpreted as key path (i.e. ['foo', 'bar'] + * will sort $var by $item['foo']['bar']) + * @param string $fallback specify what to do with items + * which don't contain the specified sort key; use "bottom" (default) + * to move those items to the end of the sorted array, "top" to rank + * them first, or "keep" to keep the original order of those items + * @return array sorted array + */ + public function sortByFilter($var, $sortKeyPath, $fallback = 'bottom') + { + if (is_object($var) && is_a($var, 'Traversable')) { + $var = iterator_to_array($var, true); + } elseif (!is_array($var)) { + throw new Twig_Error_Runtime(sprintf( + 'The sort_by filter only works with arrays or "Traversable", got "%s"', + is_object($var) ? get_class($var) : gettype($var) + )); + } + if (($fallback !== 'top') && ($fallback !== 'bottom') && ($fallback !== 'keep')) { + throw new Twig_Error_Runtime('The sort_by filter only supports the "top", "bottom" and "keep" fallbacks'); + } + + $twigExtension = $this; + $varKeys = array_keys($var); + uksort($var, function ($a, $b) use ($twigExtension, $var, $varKeys, $sortKeyPath, $fallback, &$removeItems) { + $aSortValue = $twigExtension->getKeyOfVar($var[$a], $sortKeyPath); + $aSortValueNull = ($aSortValue === null); + + $bSortValue = $twigExtension->getKeyOfVar($var[$b], $sortKeyPath); + $bSortValueNull = ($bSortValue === null); + + if ($aSortValueNull xor $bSortValueNull) { + if ($fallback === 'top') { + return ($aSortValueNull - $bSortValueNull) * -1; + } elseif ($fallback === 'bottom') { + return ($aSortValueNull - $bSortValueNull); + } + } elseif (!$aSortValueNull && !$bSortValueNull) { + if ($aSortValue != $bSortValue) { + return ($aSortValue > $bSortValue) ? 1 : -1; + } + } + + // never assume equality; fallback to original order + $aIndex = array_search($a, $varKeys); + $bIndex = array_search($b, $varKeys); + return ($aIndex > $bIndex) ? 1 : -1; + }); + + return $var; + } + + /** + * Returns the value of a variable item specified by a scalar key or a + * arbitrary deep sub-key using a key path + * + * @param array|Traversable|ArrayAccess|object $var base variable + * @param mixed $keyPath scalar key or a + * array interpreted as key path (when passing e.g. ['foo', 'bar'], + * the method will return $var['foo']['bar']) specifying the value + * @return mixed the requested + * value or NULL when the given key or key path didn't match + */ + public static function getKeyOfVar($var, $keyPath) + { + if (empty($keyPath)) { + return null; + } elseif (!is_array($keyPath)) { + $keyPath = array($keyPath); + } + + foreach ($keyPath as $key) { + if (is_object($var)) { + if (is_a($var, 'ArrayAccess')) { + // use ArrayAccess, see below + } elseif (is_a($var, 'Traversable')) { + $var = iterator_to_array($var); + } elseif (isset($var->{$key})) { + $var = $var->{$key}; + continue; + } elseif (is_callable(array($var, 'get' . ucfirst($key)))) { + try { + $var = call_user_func(array($var, 'get' . ucfirst($key))); + continue; + } catch (BadMethodCallException $e) { + return null; + } + } else { + return null; + } + } elseif (!is_array($var)) { + return null; + } + + if (isset($var[$key])) { + $var = $var[$key]; + continue; + } + + return null; + } + + return $var; + } +}