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/" %}
@@ -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;
+ }
+}