From f19adc64b9952f33975e5589dca19b94e0971bd0 Mon Sep 17 00:00:00 2001 From: Daniel Rudolf Date: Fri, 13 Nov 2015 16:48:01 +0100 Subject: [PATCH 1/7] Add PicoTwigExtension with map and sort_by filters --- lib/Pico.php | 1 + lib/PicoTwigExtension.php | 222 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 223 insertions(+) create mode 100644 lib/PicoTwigExtension.php diff --git a/lib/Pico.php b/lib/Pico.php index be75671..b315d72 100644 --- a/lib/Pico.php +++ b/lib/Pico.php @@ -1117,6 +1117,7 @@ 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(); } diff --git a/lib/PicoTwigExtension.php b/lib/PicoTwigExtension.php new file mode 100644 index 0000000..d62e509 --- /dev/null +++ b/lib/PicoTwigExtension.php @@ -0,0 +1,222 @@ +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( + 'map' => new Twig_SimpleFilter('map', array($this, 'mapFilter')), + 'sort_by' => new Twig_SimpleFilter('sort_by', array($this, 'sortByFilter')), + ); + } + + /** + * 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 InvalidArgumentException( + 'Unable to apply Twig "map" filter: ' + . 'You must pass a traversable variable' + ); + } + if (empty($mapKeyPath)) { + throw new InvalidArgumentException( + 'Unable to apply Twig "map" filter: ' + . 'You must specify the $mapKeyPath parameter' + ); + } + + $result = array(); + foreach ($var as $key => $value) { + $result[$key] = $this->getKeyOfVar($value, $mapKeyPath); + } + 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 InvalidArgumentException( + 'Unable to apply Twig "sort_by" filter: ' + . 'You must pass a traversable variable' + ); + } + if (empty($sortKeyPath)) { + throw new InvalidArgumentException( + 'Unable to apply Twig "sort_by" filter: ' + . 'You must specify the $sortKeyPath parameter' + ); + } + if (($fallback !== 'top') && ($fallback !== 'bottom') && ($fallback !== 'keep')) { + throw new InvalidArgumentException( + 'Unable to apply Twig "sort_by" filter: ' + . 'Invalid $fallback parameter: ' . $fallback + ); + } + + $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 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, 'Traversable')) { + $var = iterator_to_array($var); + } elseif (isset($var->{$key})) { + $var = $var->{$key}; + continue; + } elseif (is_callable(array($var, 'get' . ucfirst($key)))) { + $var = call_user_func(array($var, 'get' . ucfirst($key))); + continue; + } elseif (!is_a($var, 'ArrayAccess')) { + return null; + } + } elseif (!is_array($var)) { + return null; + } + + if (isset($var[$key])) { + $var = $var[$key]; + continue; + } + + return null; + } + + return $var; + } +} From 19cbb41222554444b0ebd6f9f1df8a41d455b363 Mon Sep 17 00:00:00 2001 From: Daniel Rudolf Date: Fri, 13 Nov 2015 16:48:20 +0100 Subject: [PATCH 2/7] Update CHANGELOG.md --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d211dc2..5b920e9 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 * [Changed] Reuse `ParsedownExtra` object; new `onParsedownRegistration` event * [Fixed] Replace `urldecode()` (deprecated RFC 1738) with `rawurldecode()` (RFC 3986) in `Page::evaluateRequestUrl()` From 10de8efa112f70bb04ae68b91d04a8662079d9f0 Mon Sep 17 00:00:00 2001 From: Daniel Rudolf Date: Fri, 13 Nov 2015 16:49:53 +0100 Subject: [PATCH 3/7] Move markdown filter to PicoTwigExtension --- lib/Pico.php | 31 ++++++++----------------------- lib/PicoTwigExtension.php | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/lib/Pico.php b/lib/Pico.php index b315d72..42454e2 100644 --- a/lib/Pico.php +++ b/lib/Pico.php @@ -1109,6 +1109,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 */ @@ -1119,22 +1122,13 @@ class Pico $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])) { @@ -1147,15 +1141,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 index d62e509..b829f1b 100644 --- a/lib/PicoTwigExtension.php +++ b/lib/PicoTwigExtension.php @@ -59,11 +59,35 @@ class PicoTwigExtension extends Twig_Extension 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 * From c17f18f8cf43d2fc70e16e2308d9da4a0bc8e96a Mon Sep 17 00:00:00 2001 From: Daniel Rudolf Date: Fri, 13 Nov 2015 17:08:12 +0100 Subject: [PATCH 4/7] Update inline user docs to reflect PicoTwigExtension --- content-sample/index.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/content-sample/index.md b/content-sample/index.md index 074ded8..389a09a 100644 --- a/content-sample/index.md +++ b/content-sample/index.md @@ -205,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 From 2ce422d907fe4587090d25ab7c35e1b69e219693 Mon Sep 17 00:00:00 2001 From: Daniel Rudolf Date: Sun, 29 Nov 2015 22:18:41 +0100 Subject: [PATCH 5/7] PicoTwigExtension: Refactor error handling --- lib/PicoTwigExtension.php | 52 +++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 30 deletions(-) diff --git a/lib/PicoTwigExtension.php b/lib/PicoTwigExtension.php index b829f1b..c249ff4 100644 --- a/lib/PicoTwigExtension.php +++ b/lib/PicoTwigExtension.php @@ -103,21 +103,16 @@ class PicoTwigExtension extends Twig_Extension public function mapFilter($var, $mapKeyPath) { if (!is_array($var) && (!is_object($var) || !is_a($var, 'Traversable'))) { - throw new InvalidArgumentException( - 'Unable to apply Twig "map" filter: ' - . 'You must pass a traversable variable' - ); - } - if (empty($mapKeyPath)) { - throw new InvalidArgumentException( - 'Unable to apply Twig "map" filter: ' - . 'You must specify the $mapKeyPath parameter' - ); + 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) { - $result[$key] = $this->getKeyOfVar($value, $mapKeyPath); + $mapValue = $this->getKeyOfVar($value, $mapKeyPath); + $result[$key] = ($mapValue !== null) ? $mapValue : $value; } return $result; } @@ -149,22 +144,13 @@ class PicoTwigExtension extends Twig_Extension if (is_object($var) && is_a($var, 'Traversable')) { $var = iterator_to_array($var, true); } elseif (!is_array($var)) { - throw new InvalidArgumentException( - 'Unable to apply Twig "sort_by" filter: ' - . 'You must pass a traversable variable' - ); - } - if (empty($sortKeyPath)) { - throw new InvalidArgumentException( - 'Unable to apply Twig "sort_by" filter: ' - . 'You must specify the $sortKeyPath parameter' - ); + 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 InvalidArgumentException( - 'Unable to apply Twig "sort_by" filter: ' - . 'Invalid $fallback parameter: ' . $fallback - ); + throw new Twig_Error_Runtime('The sort_by filter only supports the "top", "bottom" and "keep" fallbacks'); } $twigExtension = $this; @@ -206,7 +192,7 @@ class PicoTwigExtension extends Twig_Extension * 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 the given key or key path didn't match + * value or NULL when the given key or key path didn't match */ public static function getKeyOfVar($var, $keyPath) { @@ -218,15 +204,21 @@ class PicoTwigExtension extends Twig_Extension foreach ($keyPath as $key) { if (is_object($var)) { - if (is_a($var, 'Traversable')) { + 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)))) { - $var = call_user_func(array($var, 'get' . ucfirst($key))); - continue; - } elseif (!is_a($var, 'ArrayAccess')) { + try { + $var = call_user_func(array($var, 'get' . ucfirst($key))); + continue; + } catch (BadMethodCallException $e) { + return null; + } + } else { return null; } } elseif (!is_array($var)) { From 24b58175a48346e38ca46cb35625a5d7cb93c1de Mon Sep 17 00:00:00 2001 From: Daniel Rudolf Date: Sun, 29 Nov 2015 22:28:18 +0100 Subject: [PATCH 6/7] Inline docs: Update Blogging section to use "sort_by" filter --- content-sample/index.md | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/content-sample/index.md b/content-sample/index.md index 218646a..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 From 3c10c8ac0b7634885e53fcf396395013813fc696 Mon Sep 17 00:00:00 2001 From: theshka Date: Sun, 29 Nov 2015 19:59:34 -0600 Subject: [PATCH 7/7] fix typo --- content-sample/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content-sample/index.md b/content-sample/index.md index 8d4474e..243df96 100644 --- a/content-sample/index.md +++ b/content-sample/index.md @@ -156,7 +156,7 @@ 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` +* `{{ config }}` - Contains 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; use Twigs `link` filter to