canax/content

View on GitHub
src/Content/FileBasedContent.php

Summary

Maintainability
D
2 days
Test Coverage
<?php

namespace Anax\Content;

use Anax\Commons\ContainerInjectableInterface;
use Anax\Commons\ContainerInjectableTrait;
use Anax\Route\Exception\NotFoundException;

/**
 * Pages based on file content.
 */
class FileBasedContent implements ContainerInjectableInterface
{
    use ContainerInjectableTrait,
        FBCBreadcrumbTrait,
        FBCLoadAdditionalContentTrait,
        FBCUtilitiesTrait;



    /**
     * All routes.
     */
    private $index = null;

    /**
     * All authors.
     */
    private $author = null;

    /**
     * All categories.
     */
    private $category = null;

    /**
     * All routes having meta.
     */
    private $meta = null;

    /**
     * This is the base route.
     */
    private $baseRoute = null;

    /**
     * This is the extendede meta route, if any.
     */
    private $metaRoute = null;

    /**
     * This is the current page, to supply pagination, if used.
     */
    private $currentPage = null;

    /**
     * Use cache or recreate each time.
     */
    private $ignoreCache = false;
    
    /**
     * File name pattern, all files must match this pattern and the first
     * numbered part is optional, the second part becomes the route.
     */
    private $filenamePattern = "#^(\d*)_*([^\.]+)\.md$#";

    /**
     * Internal routes that is marked as internal content routes and not
     * exposed as public routes.
     */
    private $internalRouteDirPattern = [
        "#block/#",
    ];

    private $internalRouteFilePattern = [
        "#^block[_-]{1}#",
        "#^_#",
    ];

    /**
     * Routes that should be used in toc.
     */
    private $allowedInTocPattern = "([\d]+_(\w)+)";



    /**
     * Set default values from configuration.
     *
     * @param array $config the configuration to use.
     *
     * @return void
     */
    public function configure(array $config) : void
    {
        $this->config = $config;
        $this->setDefaultsFromConfiguration();
    }



    /**
     * Set default values from configuration.
     *
     * @return this.
     */
    private function setDefaultsFromConfiguration()
    {
        $this->ignoreCache = isset($this->config["ignoreCache"])
            ? $this->config["ignoreCache"]
            : $this->ignoreCache;

        return $this;
    }



    /**
     * Should the cache be used or ignored.
     *
     * @param boolean $use true to use the cache or false to ignore the cache
     *
     * @return this.
     */
    public function useCache($use)
    {
        $this->ignoreCache = !$use;

        return $this;
    }



    /**
     * Create the index of all content into an array.
     *
     * @param string $type of index to load.
     *
     * @return void.
     */
    private function load($type)
    {
        $index = $this->$type;
        if ($index) {
            return;
        }

        $cache = $this->di->get("cache");
        $key = $cache->createKey(__CLASS__, $type);
        $index = $cache->get($key);

        if (is_null($index) || $this->ignoreCache) {
            $createMethod = "create$type";
            $index = $this->$createMethod();
            $cache->set($key, $index);
        }

        $this->$type = $index;
    }




    // = Create and manage index ==================================

    /**
     * Generate an index from the directory structure.
     *
     * @return array as index for all content files.
     */
    private function createIndex()
    {
        $basepath   = $this->config["basePath"];
        $pattern    = $this->config["pattern"];
        $path       = "$basepath/$pattern";

        $index = [];
        foreach (glob_recursive($path) as $file) {
            $filepath = substr($file, strlen($basepath) + 1);

            // Find content files
            $matches = [];
            preg_match($this->filenamePattern, basename($filepath), $matches);
            $dirpart = dirname($filepath) . "/";
            if ($dirpart === "./") {
                $dirpart = null;
            }
            $key = $dirpart . $matches[2];
            
            // Create level depending on the file id
            // TODO ciamge doc, can be replaced by __toc__ in meta?
            $id = (int) $matches[1];
            $level = 2;
            if ($id % 100 === 0) {
                $level = 0;
            } elseif ($id % 10 === 0) {
                $level = 1;
            }

            $index[$key] = [
                "file"     => $filepath,
                "section"  => $matches[1],
                "level"    => $level,  // TODO ?
                "internal" => $this->isInternalRoute($filepath),
                "tocable"  => $this->allowInToc($filepath),
            ];
        }

        return $index;
    }



    /**
     * Check if a filename is to be marked as an internal route..
     *
     * @param string $filepath as the basepath (routepart) to the file.
     *
     * @return boolean true if the route content is internal, else false
     */
    private function isInternalRoute($filepath)
    {
        foreach ($this->internalRouteDirPattern as $pattern) {
            if (preg_match($pattern, $filepath)) {
                return true;
            }
        }

        $filename = basename($filepath);
        foreach ($this->internalRouteFilePattern as $pattern) {
            if (preg_match($pattern, $filename)) {
                return true;
            }
        }

        return false;
    }



    /**
     * Check if filepath should be used as part of toc.
     *
     * @param string $filepath as the basepath (routepart) to the file.
     *
     * @return boolean true if the route content shoul dbe in toc, else false
     */
    private function allowInToc($filepath)
    {
        return (boolean) preg_match($this->allowedInTocPattern, $filepath);
    }



    // = Create and manage meta ==================================

    /**
     * Generate an index for meta files.
     *
     * @return array as meta index.
     */
    private function createMeta()
    {
        $basepath = $this->config["basePath"];
        $filter   = $this->config["textfilter-frontmatter"];
        $pattern  = $this->config["meta"];
        $path     = "$basepath/$pattern";
        $textfilter = $this->di->get("textfilter");

        $index = [];
        foreach (glob_recursive($path) as $file) {
            // The key entry to index
            $key = dirname(substr($file, strlen($basepath) + 1));

            // Get info from base document
            $src = file_get_contents($file);
            $filtered = $textfilter->parse($src, $filter);
            $index[$key] = $filtered->frontmatter;

            // Add Toc to the data array
            $index[$key]["__toc__"] = $this->createBaseRouteToc($key);
        }

        // Add author details
        $this->meta = $index;
        $this->createAuthor();
        $this->createCategory();

        return $this->meta;
    }



    /**
     * Get a reference to meta data for specific route.
     *
     * @param string $route current route used to access page.
     *
     * @return array as table of content.
     */
    private function getMetaForRoute($route)
    {
        $base = dirname($route);
        return isset($this->meta[$base])
            ? $this->meta[$base]
            : [];
    }



    /**
     * Create a table of content for routes at particular level.
     *
     * @param string $route base route to use.
     *
     * @return array as the toc.
     */
    private function createBaseRouteToc($route)
    {
        $toc = [];
        $len = strlen($route);

        foreach ($this->index as $key => $value) {
            if (substr($key, 0, $len + 1) === "$route/") {
                if ($value["internal"] === false
                    && $value["tocable"] === true) {
                    $toc[$key] = $value;
                    
                    $frontm = $this->getFrontmatter($value["file"]);
                    $toc[$key]["title"] = $frontm["title"];
                    $toc[$key]["publishTime"] = $this->getPublishTime($frontm);
                    $toc[$key]["sectionHeader"] = isset($frontm["sectionHeader"])
                        ? $frontm["sectionHeader"]
                        : null;
                    $toc[$key]["linkable"] = isset($frontm["linkable"])
                        ? $frontm["linkable"]
                        : null;
                }
            }
        };

        return $toc;
    }



    // = Deal with authors ====================================
    
    /**
     * Generate a lookup index for authors that maps into the meta entry
     * for the author.
     *
     * @return void.
     */
    private function createAuthor()
    {
        $pattern = $this->config["author"];

        $index = [];
        $matches = [];
        foreach ($this->meta as $key => $entry) {
            if (preg_match($pattern, $key, $matches)) {
                $acronym = $matches[1];
                $index[$acronym] = $key;
                $this->meta[$key]["acronym"] = $acronym;
                $this->meta[$key]["url"] = $key;
                unset($this->meta[$key]["__toc__"]);

                // Get content for byline
                $route = "$key/byline";
                $data = $this->getDataForAdditionalRoute($route);
                $byline = isset($data["data"]["content"]) ? $data["data"]["content"] : null;
                $this->meta[$key]["byline"] = $byline;
            }
        }

        return $index;
    }



    /**
     * Load details for the author.
     *
     * @param array|string $author with details on the author(s).
     *
     * @return array with more details on the authors(s).
     */
    private function loadAuthorDetails($author)
    {
        if (is_array($author) && is_array(array_values($author)[0])) {
            return $author;
        }

        if (!is_array($author)) {
            $tmp = $author;
            $author = [];
            $author[] = $tmp;
        }

        $authors = [];
        foreach ($author as $acronym) {
            if (isset($this->author[$acronym])) {
                $key = $this->author[$acronym];
                $authors[$acronym] = $this->meta[$key];
            } else {
                $authors[$acronym]["acronym"] = $acronym;
            }
        }

        return $authors;
    }



    // = Deal with categories ====================================
    
    /**
     * Generate a lookup index for categories that maps into the meta entry
     * for the category.
     *
     * @return void.
     */
    private function createCategory()
    {
        $pattern = $this->config["category"];

        $index = [];
        $matches = [];
        foreach ($this->meta as $key => $entry) {
            if (preg_match($pattern, $key, $matches)) {
                $catKey = $matches[1];
                $index[$catKey] = $key;
                $this->meta[$key]["key"] = $catKey;
                $this->meta[$key]["url"] = $key;
                unset($this->meta[$key]["__toc__"]);
            }
        }

        return $index;
    }



    /**
     * Find next and previous links of current content.
     *
     * @param array|string $author with details on the category(s).
     *
     * @return array with more details on the category(s).
     */
    private function loadCategoryDetails($category)
    {
        if (is_array($category) && is_array(array_values($category)[0])) {
            return $category;
        }

        if (!is_array($category)) {
            $tmp = $category;
            $category = [];
            $category[] = $tmp;
        }

        $categorys = [];
        foreach ($category as $catKey) {
            if (isset($this->category[$catKey])) {
                $key = $this->category[$catKey];
                $categorys[$catKey] = $this->meta[$key];
            } else {
                $categorys[$catKey]["key"] = $catKey;
            }
        }

        return $categorys;
    }




    // == Used by meta and breadcrumb (to get title) ===========================
    // TODO REFACTOR THIS?
    // Support getting only frontmatter.
    // Merge with function that retrieves whole filtered since getting
    // frontmatter will involve full parsing of document.
    // Title is retrieved from the HTML code.
    // Also do cacheing of each retrieved and parsed document
    // in this cycle, to gather code that loads and parses a individual
    // document.
    
    /**
     * Get the frontmatter of a document.
     *
     * @param string $file to get frontmatter from.
     *
     * @return array as frontmatter.
     */
    private function getFrontmatter($file)
    {
        $basepath = $this->config["basePath"];
        $filter1  = $this->config["textfilter-frontmatter"];
        $filter2  = $this->config["textfilter-title"];
        $filter = array_merge($filter1, $filter2);
        
        $path = $basepath . "/" . $file;
        $src = file_get_contents($path);
        $filtered = $this->di->get("textfilter")->parse($src, $filter);
        return $filtered->frontmatter;
    }



    // == Look up route in index ===================================
    
    /**
     * Check if currrent route is a supported meta route.
     *
     * @param string $route current route used to access page.
     *
     * @return string as route.
     */
    private function checkForMetaRoute($route)
    {
        $this->baseRoute = $route;
        $this->metaRoute = null;

        // If route exits in index, use it
        if ($this->mapRoute2IndexKey($route)) {
            return $route;
        }

        // Check for pagination
        $pagination = $this->config["pagination"];
        $matches = [];
        $pattern = "/(.*?)\/($pagination)\/(\d+)$/";
        if (preg_match($pattern, $route, $matches)) {
            $this->baseRoute = $matches[1];
            $this->metaRoute = $route;
            $this->currentPage = $matches[3];
        }

        return $this->baseRoute;
    }



    /**
     * Map the route to the correct key in the index.
     *
     * @param string $route current route used to access page.
     *
     * @return string as key or false if no match.
     */
    private function mapRoute2IndexKey($route)
    {
        $route = rtrim($route, "/");

        if (key_exists($route, $this->index)) {
            return $route;
        } elseif (empty($route) && key_exists("index", $this->index)) {
            return "index";
        } elseif (key_exists($route . "/index", $this->index)) {
            return "$route/index";
        }

        return false;
    }



    /**
     * Map the route to the correct entry in the index.
     *
     * @param string $route current route used to access page.
     *
     * @return array as the matched route.
     */
    private function mapRoute2Index($route)
    {
        $routeIndex = $this->mapRoute2IndexKey($route);

        if ($routeIndex) {
            return [$routeIndex, $this->index[$routeIndex]];
        }

        $msg = t("The route '!ROUTE' does not exists in the index.", [
            "!ROUTE" => $route
        ]);
        throw new NotFoundException($msg);
    }



    // = Get view data by merging from meta and current frontmatter =========
    
    /**
     * Get view by mergin information from meta and frontmatter.
     *
     * @param string $route       current route used to access page.
     * @param array  $frontmatter for the content.
     * @param string $key         for the view to retrive.
     *
     * @return array with data to add as view.
     */
    private function getView($route, $frontmatter, $key)
    {
        $view = [];

        // From meta frontmatter
        $meta = $this->getMetaForRoute($route);
        if (isset($meta[$key])) {
            $view = $meta[$key];
        }

        // From document frontmatter
        if (isset($frontmatter[$key])) {
            $view = array_merge_recursive_distinct($view, $frontmatter[$key]);
            //$view = array_merge($view, $frontmatter[$key]);
        }

        return $view;
    }



    /**
     * Get details on extra views.
     *
     * @param string $route       current route used to access page.
     * @param array  $frontmatter for the content.
     *
     * @return array with page data to send to view.
     */
    private function getViews($route, $frontmatter)
    {
        // Arrange data into views
        $views = $this->getView($route, $frontmatter, "views", true);

        // Set defaults
        if (!isset($views["main"]["template"])) {
            $views["main"]["template"] = $this->config["template"];
        }
        if (!isset($views["main"]["data"])) {
            $views["main"]["data"] = [];
        }

        // Merge remaining frontmatter into view main data.
        $data = $this->getMetaForRoute($route);
        unset($data["__toc__"]);
        unset($data["views"]);
        unset($frontmatter["views"]);

        if ($frontmatter) {
            $data = array_merge_recursive_distinct($data, $frontmatter);
        }
        $views["main"]["data"] = array_merge_recursive_distinct($views["main"]["data"], $data);

        return $views;
    }



    // == Create and load content ===================================

    /**
     * Map url to content, even internal content, if such mapping can be done.
     *
     * @param string $route route to look up.
     *
     * @return object with content and filtered version.
     */
    private function createContentForInternalRoute($route)
    {
        // Load index and map route to content
        $this->load("index");
        $this->load("meta");
        $this->load("author");
        $this->load("category");
        
        // Match the route
        $route = rtrim($route, "/");
        $route = $this->checkForMetaRoute($route);
        list($routeIndex, $content, $filtered) = $this->mapRoute2Content($route);

        // Create and arrange the content as views, merge with .meta,
        // frontmatter is complete.
        $content["views"] = $this->getViews($routeIndex, $filtered->frontmatter);

        // Do process content step two when all frontmatter is included.
        $this->processMainContentPhaseTwo($content, $filtered);
        
        // Set details of content
        $content["views"]["main"]["data"]["content"] = $filtered->text;
        $content["views"]["main"]["data"]["excerpt"] = $filtered->excerpt;
        $this->loadAdditionalContent($content["views"], $route, $routeIndex);

        // TODO Should not supply all frontmatter to theme, only the
        // parts valid to the index template. Separate that data into own
        // holder in frontmatter. Do not include whole frontmatter? Only
        // on debg?
        $content["frontmatter"] = $filtered->frontmatter;

        return (object) $content;
    }



    /**
     * Look up the route in the index and use that to retrieve the filtered
     * content.
     *
     * @param string $route to look up.
     *
     * @return array with content and filtered version.
     */
    private function mapRoute2Content($route)
    {
        // Look it up in the index
        list($keyIndex, $content) = $this->mapRoute2Index($route);
        $filtered = $this->loadFileContentPhaseOne($keyIndex);

        return [$keyIndex, $content, $filtered];
    }



    /**
     * Load content file and frontmatter, this is the first time we process
     * the content.
     *
     * @param string $key     to index with details on the route.
     *
     * @throws NotFoundException when mapping can not be done.
     *
     * @return void.
     */
    private function loadFileContentPhaseOne($key)
    {
        // Settings from config
        $basepath = $this->config["basePath"];
        $filter   = $this->config["textfilter-frontmatter"];

        // Whole path to file
        $path = $basepath . "/" . $this->index[$key]["file"];

        // Load content from file
        if (!is_file($path)) {
            $msg = t("The content '!ROUTE' does not exists as a file '!FILE'.", ["!ROUTE" => $key, "!FILE" => $path]);
            throw new \Anax\Exception\NotFoundException($msg);
        }

        // Get filtered content
        $src = file_get_contents($path);
        $filtered = $this->di->get("textfilter")->parse($src, $filter);

        return $filtered;
    }



    // == Process content phase 2 ===================================
    // TODO REFACTOR THIS?
    
    /**
     * Look up the route in the index and use that to retrieve the filtered
     * content.
     *
     * @param array  &$content   to process.
     * @param object &$filtered to use for settings.
     *
     * @return array with content and filtered version.
     */
    private function processMainContentPhaseTwo(&$content, &$filtered)
    {
        // From configuration
        $filter = $this->config["textfilter"];
        $revisionStart = $this->config["revision-history"]["start"];
        $revisionEnd   = $this->config["revision-history"]["end"];
        $revisionClass = $this->config["revision-history"]["class"];
        $revisionSource = isset($this->config["revision-history"]["source"])
            ? $this->config["revision-history"]["source"]
            : null;

        $textFilter = $this->di->get("textfilter");
        $text = $filtered->text;

        // Check if revision history is to be included
        if (isset($content["views"]["main"]["data"]["revision"])) {
            $text = $textFilter->addRevisionHistory(
                $text,
                $content["views"]["main"]["data"]["revision"],
                $revisionStart,
                $revisionEnd,
                $revisionClass,
                $revisionSource . "/" . $content["file"]
            );
        }

        // Get new filtered content (and updated frontmatter)
        // Title in frontmatter overwrites title found in content
        $new = $textFilter->parse($text, $filter);
        $filtered->text = $new->text;
         
        // Keep title if defined in frontmatter
        $title = isset($filtered->frontmatter["title"])
          ? $filtered->frontmatter["title"]
          : null;

        $filtered->frontmatter = array_merge_recursive_distinct(
            $filtered->frontmatter,
            $new->frontmatter
        );

        if ($title) {
            $filtered->frontmatter["title"] = $title;
        }

        // Main data is
        $data = &$content["views"]["main"]["data"];

        // Update all anchor urls to use baseurl, needs info about baseurl
        // from merged frontmatter
        $baseurl = isset($data["baseurl"])
          ? $data["baseurl"]
          : null;
        $this->addBaseurl2AnchorUrls($filtered, $baseurl);
        $this->addBaseurl2ImageSource($filtered, $baseurl);

        // Add excerpt and hasMore, if available
        $textFilter->addExcerpt($filtered);

        // Load details on author, if set.
        if (isset($data["author"])) {
            $data["author"] = $this->loadAuthorDetails($data["author"]);
        }

        // Load details on category, if set.
        if (isset($data["category"])) {
            $data["category"] = $this->loadCategoryDetails($data["category"]);
        }
    }



    // == Public methods ============================================
    
    /**
     * Map url to content, even internal content, if such mapping can be done.
     *
     * @param string $route optional route to look up.
     *
     * @return object with content and filtered version.
     */
    public function contentForInternalRoute($route = null)
    {
        // Get the route
        if (is_null($route)) {
            $route = $this->di->get("request")->getRoute();
        }

        // Check cache for content or create cached version of content
        $cache = $this->di->get("cache");
        $slug = $this->di->get("url")->slugify($route);
        $key = $cache->createKey(__CLASS__, "route-$slug");
        $content = $cache->get($key);

        if (!$content || $this->ignoreCache) {
            $content = $this->createContentForInternalRoute($route);
            $cache->set($key, $content);
        }

        return $content;
    }



    /**
     * Map url to content if such mapping can be done, exclude internal routes.
     *
     * @param string $route optional route to look up.
     *
     * @return object with content and filtered version.
     */
    public function contentForRoute($route = null)
    {
        $content = $this->contentForInternalRoute($route);
        if ($content->internal === true) {
            $msg = t("The content '!ROUTE' does not exists as a public route.", ["!ROUTE" => $route]);
            throw new NotFoundException($msg);
        }

        return $content;
    }
}