For version 2 of my DirectoryListing-plugin I wanted to move away from the folder-traversal I had previously utilized. This post details how to programmatically iterate through Grav’s pages recursively, and gather information about page-media along the way. By the end, we’ll have produced a structure like this:
Features
├── default.md
├── Download
│ └── default.md
├── Documentation
├── Typography
│ └── default.md
├── Block Variations
└── Particles
In version 1 I used PHP’s built-in scandir()-function to recurse through Grav’s /user/pages/-folder and build a HTML-list from it. This worked well enough because all of Grav’s content is essentially kept in and below this folder, but the result was limited to folder- and file-names.
To improve upon this, iterating through Grav’s actual Pages-object will return much more useful data, in particular titles, routes, and paths. However, rather than access this object directly I am accessing it by creating a collection from the current page. This allows using page-header variables and sorting like normal pages.
Creating Utilities #
Rather than construct a large and convoluted
directorylisting.php
, I split the core functionality
into Utilities.php
, which is also used by the plugin’s
Twig-extension (more on that later). This file declares a class –
“Utilities” – with four methods:
-
build()
, which initiates the process and resolves configuration -
buildTree()
recursively iterates through pages and media -
buildList()
recursively iterates through the previously created tree to form a HTML-list -
arrayExcept()
, a helper-method to remove a key from an array
The class is instantiated from directorylisting.php
,
and returns the directorylisting
Twig-variable. This is
done in the “onTwigPageVariables”-event. In addition to the
default plugin-configuration, any page can override it. This is facilitated through:
/**
* Builds hierarchical HTML-list of pages and media
* @return string HTML-List
*/
public function output()
{
$config = (array) $this->config->get('plugins.directorylisting');
$page = $this->grav['page'];
$route = $page->route();
$config = $this->mergeConfig($page);
$utility = new Utilities($config);
$list = $utility->build($route);
$this->grav['twig']->twig_vars['directorylisting'] = '<div class="directorylist">' . $list . '</div>';
}
First, it retrieves the plugin’s default-configuration, the Page-object, and the page’s route. Then it merges any page-configuration, and constructs the “Utilities”-class. Finally, it builds the HTML-list and outputs it through Twig.
Building the tree #
Internally the merged configuration is passed to the class, wherein
build()
takes over.
/**
* Sorts out configuration and initiates building
* @param string $route Route to page
* @return string HTML-list from page-structure
*/
public function build($route)
{
$config = $this->config;
$tree = $this->buildTree($route);
if ($config['include_additional']) {
foreach ($config['include_additional'] as $include) {
if (is_array($include)) {
$include = $this->buildTree($include, '@page.self');
$tree = array_merge($tree, $include);
}
}
}
if ($config['exclude_additional']) {
foreach ($config['exclude_additional'] as $exclude) {
if (is_array($exclude)) {
$this->arrayExcept($tree, array($exclude));
}
}
}
if ($tree) {
$list = $this->buildList($tree);
return $list;
} else {
return false;
}
}
Here a tree-structure of pages is constructed, and additional pages are included or excluded based on the retrieved configuration. The tree is constructed thuswise:
/**
* Creates page-structure recursively
* @param string $route Route to page
* @param integer $depth Reserved placeholder for recursion depth
* @return array Page-structure with children and media
*/
public function buildTree($route, $mode = false, $depth = 0)
{
$config = $this->config;
$page = Grav::instance()['page'];
$depth++;
$mode = '@page.self';
if ($depth > 1) {
$mode = '@page.children';
}
$pages = $page->evaluate([$mode => $route]);
$pages = $pages->published()->order($config['order']['by'], $config['order']['dir']);
$paths = array();
foreach ($pages as $page) {
if ($config['exclude_modular'] && isset($page->header()->content['items'])) {
if ($page->header()->content['items'] == '@self.modular') {
continue;
}
}
$route = $page->rawRoute();
$path = $page->path();
$title = $page->title();
$paths[$route]['depth'] = $depth;
$paths[$route]['title'] = $title;
$paths[$route]['route'] = $route;
$paths[$route]['name'] = $page->name();
if (!empty($paths[$route])) {
$children = $this->buildTree($route, $mode, $depth);
if (!empty($children)) {
$paths[$route]['children'] = $children;
}
}
$media = new Media($path);
foreach ($media->all() as $filename => $file) {
$paths[$route]['media'][$filename] = $file->items()['type'];
}
}
if (!empty($paths)) {
return $paths;
} else {
return null;
}
}
In buildTree()
the Pages-object is constructed. Line
11-15 does a couple of important things: The
$depth
-parameter is increased, to indicate what level
of depth the recursive array is at, but also to set the header used
by the collection. It should only target itself at the first level,
and thereafter the current page’s children.
The next two lines creates the Pages-object as a collection, and
sets the order of pages. The following foreach
-loop
first excludes modular pages, if set in the configuration, then
proceeds to add parts of each page to $paths
– named
thus because each page is identified by its route, which is unique
within Grav.
Notice, on line 32-37, that if the page was created it then searches
for children by calling itself again through
$children = $this->buildTree($route, $mode, $depth)
.
This is what makes the method – or any function in any
programming-language really – recursive. It calls itself, and in
this case passes the current page’s route, the
“@page.children”-mode, and current depth. This process continues
until no further pages below the current page is found.
Additionally, on line 38-41, the page’s media is iterated over. Every media-file, including photos, audio, videos, etc., is included and identified by its filename. Whilst this is not inherently a unique value, no filesystem will allow files with the same name in the same folder, so it is virtually unique.
Building the list #
Now, as we saw previously, buildList()
is called with
the tree as the parameter. This method does much of the same:
/**
* Creates HTML-lists recursively
* @param array $tree Page-structure with children and media
* @param integer $depth Reserved placeholder for recursion depth
* @return string HTML-list
*/
public function buildList($tree, $depth = 0)
{
$config = $this->config;
$depth++;
if ($config['builtin_css'] && $config['builtin_js'] && $depth == 1) {
$list = '<ul class="metismenu metisFolder">';
} else {
$list = '<ul>';
}
foreach ($tree as $route => $page) {
$list .= '<li class="directory';
if ($page['depth'] <= $config['level']) {
$list .= ' active';
}
$list .= '">';
if ($config['builtin_css'] && $config['builtin_js']) {
$list .= '<a href="#" aria-expanded="true" class="has-arrow">' . $page['title'] . '</a>';
} else {
if ($config['links']) {
$list .= '<a href="' . $page['route'] . '">' . $page['title'] . '</a>';
} else {
$list .= $page['title'];
}
}
if (!$config['exclude_main']) {
if ($config['links']) {
$list .= '<ul><li class="file page"><a href="' . $page['route'] . '">' . $page['name'] . '</a></li></ul>';
} else {
$list .= '<ul><li class="file page">' . $page['name'] . '</li></ul>';
}
}
if (isset($page['children'])) {
$list .= $this->buildList($page['children'], $depth);
}
if (isset($page['media'])) {
$list .= '<ul>';
foreach ($page['media'] as $filename => $type) {
if ($config['links']) {
$list .= '<li class="file ' . $type . '"><a href="' . $page['route'] . '/' . $filename . '">' . $filename . '</a></li>';
} else {
$list .= '<li class="file ' . $type . '">' . $filename . '</li>';
}
}
$list .= '</ul>';
}
$list .= '</li>';
}
$list .= '</ul>';
return $list;
}
The first lines take care of configuration and depth, as well as assigning classes if built-in CSS and JS is used.
First it checks the configuration and figures out the current depth
in the Pages-tree. It also assigns classes if the built-in CSS and
JS is used. This check is done throughout to add necessary
functionality for
metisMenu to
work. When the tree is iterated over, each page gets a list-item –
with optional link to the page if enabled – with its media listed
within it. As before, notice how line 38-40 refers to the
buildList
-method and passes the page’s children and the
depth, thus recursing until no more pages are found.
A Twig-extension #
To use directorylisting
from any Twig-template, a
DirectoryListingTwigExtension.php
is added. Simply put, this goes through exactly the same process in
building the tree and the list. The difference is that rather than
passing configuration through the page’s FrontMatter or the plugin’s
configuration-file, it is passed directly to the
Twig-function. This follows the same format and looks like this:
{% set settings = {
'route': '/features',
'exclude_main': false,
'exclude_modular': true
} %}
{{ directorylisting(settings) }}
The output #
When all this executes the following list is returned:
<div class="directorylist">
<ul class="metismenu metisFolder">
<li class="directory active">
<a href="#" aria-expanded="true" class="has-arrow">Features</a>
<ul aria-expanded="true" class="collapse in">
<li class="file page"><a href="/features">default.md</a></li>
</ul>
<ul aria-expanded="true" class="collapse in">
<li class="directory">
<a href="#" aria-expanded="true" class="has-arrow">Download</a>
<ul aria-expanded="false" class="collapse">
<li class="file page">
<a href="/features/download">default.md</a>
</li>
</ul>
</li>
<li class="directory">
<a href="#" aria-expanded="true" class="has-arrow">Documentation</a>
<ul aria-expanded="false" class="collapse">
<li class="file page">
<a href="/features/documentation">default.md</a>
</li>
</ul>
</li>
<li class="directory">
<a href="#" aria-expanded="true" class="has-arrow">Typography</a>
<ul aria-expanded="false" class="collapse">
<li class="file page">
<a href="/features/typography">default.md</a>
</li>
</ul>
</li>
<li class="directory">
<a href="#" aria-expanded="true" class="has-arrow"
>Block Variations</a
>
<ul aria-expanded="false" class="collapse">
<li class="file page">
<a href="/features/block-variations">default.md</a>
</li>
</ul>
</li>
<li class="directory">
<a href="#" aria-expanded="true" class="has-arrow">Particles</a>
<ul aria-expanded="false" class="collapse">
<li class="file page">
<a href="/features/particles">default.md</a>
</li>
</ul>
</li>
</ul>
</li>
</ul>
</div>
Which will look like this:
And that’s “all” there is to it. The same tree could be used
anywhere you’d need an exhaustive list of Grav’s pages and their
associated media. As a final tip, you can use
Grav::instance()['debugger']->addMessage($tree)
to
explore the tree in the debugger if needed, for example after line
25 in the build()
-method.
A more concise tutorial covering only the traversal of pages is also available in Grav’s Docs.