Introduction

Why yet another markdown CMS, you ask? I want my publishing experience to be as hassle free as possible. Writing a blog post should be as simple as editing a Word document and having it automatically backup to the cloud. When I add an image to a blog post I shouldn't have to preprocess and resize it but the program should to take care of that for me. I also want the coding experience to be enjoyable and not having to dread adding features or fixing bugs on the website.

The target audience of this project is primarily developers so instead of having to create custom plugins for minor and/or major features, everything can be easily baked into the theme so that there's only one project the developer needs to worry about. Perhaps controversially it's encouraged to include logic in the theme files and they can be installed, along with their dependencies, via composer, making the entire PHP ecosystem at the theme developer's disposal (although one can still separate out the logic and integrate it using a service provider if one would prefer doing so).

From personal experience, the real headache for smaller teams is not having logic mixed in with presentation but having to maintain separate repositories, keeping them in sync, and especially keeping presentation and content easiliy migratable and not dependent on each other. In Wordpress, pushing an update from one environment to another involves updating the site configuration and content, and pushing plugins and theme changes. The former two are entangled in one database, and the latter two are kept at different repositories but are entirely dependent on each other. Separating the concerns more simply between, code, configuration and content, in my opinion makes for a simpler more enjoyable developer experience.

So that was the motivation and requirements of the project. I'm sure it's not up to all coding standards or best practices but Papyrus satisfies all of the requirements for me and I'm releasing it in case it can be of benefit to someone else as well. Thanks for checking it out!

SOMA

SOMA is a framework that evolved while creating Papyrus. It's designed to provide the bare minimum and allow the developer to pick and choose components modularly, but still provide all modern conveniences to the workflow. So it's the very core of Papyrus and therefore it'd be good to quickly read through its documentation as well to understand how it works, especially how service providers are implemented.

Getting started

Server requirements

Papyrus has a few system requirements and you will need to make sure your server meets the following:

  • PHP >= 7.2.5
  • JSON PHP Extension
  • Mbstring PHP Extension
  • GD PHP Extension (or alternatively Imagick)
  • XML PHP Extension

Installation

Papyrus requires composer for dependency management

Create a new project by executing the following command in a terminal:

composer create-project soma/papyrus-project [project-directory]

The required paths need to be created by the framework before you can run Papyrus. You can do so by running the app:install command after you've configured your .env.

php appctrl app:install

Integrate into an existing project

One can also add the library to an already existing SOMA project and simply register the "meta" provider that registers all services Papyrus is composed of:

composer require soma/papyrus
return [
    // ...
    'providers' => [
        \Papyrus\Providers\PapyrusProvider::class,
    ],
    'aliases' => [
        'Content' => \Papyrus\Facades\Content::class,
        'Themes' => \Papyrus\Facades\Themes::class,
    ],
    'commands' => [
        \Soma\Commands\AppInstall::class,
        \Soma\Commands\AppUninstall::class,
        \Soma\Commands\AppRefresh::class,
        \Soma\Commands\AppTinker::class,
        \Soma\Commands\AppServe::class,
        \Soma\Commands\AppClearCache::class,
        \Papyrus\Commands\ContentRoutes::class,
        \Papyrus\Commands\ContentCompile::class,
    ],
    // ...
];

Helpers

The file helpers.php contain a couple of functions that are meant to simplify either calling app services or work with certain types of data. Please also refer to the helpers provided by Soma.

Configuration

Setup

All essential configuration is set in the .env file in the root of your project:

APP_URL="http://localhost:8000"
APP_STAGE="development"
APP_TIMEZONE="Europe/Stockholm"
APP_DEBUG=true
APP_OPTIMIZE=true
APP_CONFIG="/absolute/path/to/your/config/folder"
APP_STORAGE="/absolute/path/to/your/storage/folder"
APP_CONTENT="/absolute/path/to/your/content/folder"
APP_THEMES="/absolute/path/to/your/themes/folder"

The storage directory needs to be writable by the application.

APP_DEBUG should only be set if you're actively debugging the application since it will slow down the website's performance noticeably. APP_OPTIMIZE should always be enabled when running in a live production environment, otherwise no files will be cached and content processing will likely tank your servers performance.

There's no need for the project to be all together in the same directory, public is the web root and the .env should preferable be in the parent directory. The content can be a git repository or simply a Dropbox folder synced to your server and the content will be compiled and cached in storage. The following however is a recommended project structure:

Project/
-- config/
-- -- app.php
-- content/
-- -- index.md
-- -- config.yml
-- -- menus.yml
-- public/
-- -- index.php
-- storage/
-- themes/
-- .env
-- appctrl
-- composer.json

The environment variables are primarily used internally by SOMA or Papyrus but can also be accessed using the included helper functions.

Configuration keys

The config folder contains all the configuration files for your project. However, any keys you set in config.yml in the root of your content folder will override keys in the content config namespace. This is the recommended place to put variables that changes the rendering of your content and behavior of your site. Theme specific settings are recommended to be embedded into the theme manifest (refer to the theme documentation).

The full list of keys supported by default are as follows and these can just as well be set via content/config.yml:

return [
    'title' => 'My Site',
    'language' => 'en_US',
    'description' => 'My personal blog',

    // Default author for published contents, this can be overriden for each page
    'author' => 'John Doe',

    // 'timezone' should be one of the PHP supported: https://www.php.net/manual/en/timezones.php
    'timezone' => 'Europe/Stockholm',

    // 'date-format' sets the default format used by the format_date helper. 
    // If false or missing then the default format is the ISO-8601 standard: YYYY-MM-DD
    'date-format' => false,

    // Adds the option to type-cast specific meta keys
    'meta' => [
        'dates' => ['published', 'birthday'],
        'booleans' => ['featured'],
        'integers' => ['order'],
    ],

    // If you enable drafts you can view the draft in the web browser
    // by appending "?preview=true" to the URL.
    'drafts' => true,

    // Sets the default number of posts per page if you call paginate
    // via ContentManager or the paginate helper.
    'pagination' => 10,

    // Sets the page that will be shown if no page can be found
    '404' => '/404.md',

    // Settings for the built-in content search functionality
    'search' => [
        'low_value' => ['is', 'a', 'the', 'in', 'he', 'she', 'it', 'its', 'and'],
        'exclude' => [
            'pages/sub'
        ],        
    ],

    // The router can be disabled if you want to implement you own
    // custom logic for all requests by setting this key to false
    'router' => [
        // Handlers are callables that take a string and a Symfony Request
        // as arguments and return a boolean indicating whether
        // they handled the request or not. Check the advanced documentation
        // for more information.
        'handlers' => [],
    ],

    // Settings affecting the RSS feed. If 'feed' is set to false
    // then the built-in route handler for RSS feeds will
    // not be registered at all
    'feed' => [
        'route' => '/feed',
        'mode' => 'excerpt',
        // 'filter' will be run against the \Illuminate\Support\Collection instance
        // of pages so that custom logic can do more advanced filtering
        'filter' => function($val, $key) {
            return true;
        },
        'sort' => 'published',
        'order' => 'asc',
        'include' => [],
        'exclude' => [],
        // 'image' should be either an absolute URL or a relative path to a file
        // within the content directory.
        'image' => 'my-feed-logo.jpg',
    ],

    // If 'images' is set to false then all automatic image processing will be disabled
    'images' => [
        // This value changes what image processing driver will be used,
        // 'gd' and 'imagick' are supported.
        'driver' => 'gd',
        // Sets the image quality used in all automatic image processing
        'quality' => 80,
        'filters' => [
            // Filters need to be callable as with the content.filters
            // and can also be set via the theme manifest
            'example-filter' => \MyTheme\MyImageFilter::class,
        ],
    ],

    // Content filters (see explanation)
    'filters' => [
        'anchors' => \Papyrus\Content\Filters\AnchorFilter::class,
        'images' => \Papyrus\Content\Filters\ImageFilter::class,
        'includes' => \Papyrus\Content\Filters\ContentIncludesFilter::class,
        'hashtags' => \Papyrus\Content\Filters\HashtagFilter::class,
        'heading-id' => \Papyrus\Content\Filters\HeadingIdFilter::class,
        'heading-offset' => \Papyrus\Content\Filters\HeadingOffsetFilter::class,
        'shortcodes' => \Papyrus\Content\Filters\ShortcodeFilter::class,
        'smartypants' => \Papyrus\Content\Filters\SmartyPantsFilter::class,
    ],

    // Page mixins (see explanation)
    'mixins' => [
        \My\Custom\Mixin::class,
    ],

    // Filter settings (see explanation)
    'tag-route' => '/tag?t=',
    'includes' => [
        'markdown' => [
            'after' => '/signature.md',
        ],
        'html' => [
            'before' => '/ads/advertisement.html'
        ],
    ],

    // Cache settings (see explanation)
    'cache' => [
        'ignore-mtime' => true,
        'validate-visited' => true,
        'gateway' => false,
        'full-page' => true,
        'time' => 3600,
        'include' => false,
        'exclude' => [
            'feed'
        ],
    ],
];

Config is most easily retrieved by the config helper using dot notation, the first level being the config file name:

$siteTitle = config('content.title', 'Default value');

images.filters

Image filters need to be callables with the following definition:

function(\Papyrus\Content\Image $image) {
  
    // ...

    return $image;
}

filters

Content filters are pre and post processors for the markdown content. The default ones add support for links to other content or static resources, responsive image resizing, id attribute on headings, heading offset and shortcodes. Some filter settings that are related to presentation are set in the theme manifest. Shortcodes require further logic and should be registered by a Service Provider (see the advanced documentation). Further information regarding the filters and their definition can be found under the Pages documentation.

mixins

Mixins are a way to extend the functionality of the Page class. Refer to the advanced documentation for more information.

tag-route

Used by the hashtags filter and sets the route which will be prefixed onto the tag string for the generated anchor.

includes

Enables automatic content insertion into every page. The content can either be markdown or pre-formatted html and the options are to either append or prepend it to the content. The value should be either a path relative to the content directory or an array of such paths.

cache

Performance can be improved by adjusting the cache behavior. ignore-mtime disables checking the modification date of the file and only forces a recompile if the file is missing completely. If this is set one must manually perform a recompilation in order to reflect content updates which could be performed by a schedule server job. validate-visited modifies that behavior to just validate the page being requested, so that other files (like when showing an archive on an index page) won't be recompiled until they are directly visited and this is a reasonable performance optimization which is recommended to turn on. Refer to the advanced documentation for more on performance and how Papyrus loads content.

If you have a HTTP cache gateway configured you can set the correct headers on the response by setting gateway to true. Alternatively you can use the full-page cache that is a simple cache system that bypasses most of Papyrus' subsystems and returns a previously rendered response to an URI request if it exists and is not older than time in seconds.

You can also set what routes you want to be cacheable using the include and exclude lists. Include runs first and if the beginning of the route doesn't match the configured filter then it won't be cached. You can also exclude to filter out specific routes within the included set.

Pages and routing

The body of the page files are formatted using Markdown and the meta using a YAML header section, other static CMS's usually uses a term borrowed from the publishing industry and call this the "front matter". The meta keys "keywords" and can be defined using a comma separated list and created as an array even though it's not a valid YAML array. This is purely for convenience sake and what keys are to be treated like this can be configured by the static array \Papyrus\Content\Page::$metaCommaArrays (also configurable with the config key content.meta.comma-arrays). The first character of each key is also made lowercase.

Here's an example of what a file could look like:

---
Title: About
Keywords: Who I am, developer, photography
Author: John Doe
Template: page
Published: 2019-07-01
---
# About me

Commodi qui sed laudantium quaerat est. Fugit temporibus occaecati ut quos nesciunt in assumenda veritatis. Architecto blanditiis similique odit. Sequi repellat sunt aut. In mollitia et occaecati quod laboriosam occaecati.

![Photo of me](portrait.jpg)

The Markdown engine is Parsedown with the Markdown Extra extension. Links are automatically resolved and images are processed to create responsive tags for each so that content creation is as hassle free as possible.

Meta

The front matter, or meta, is processed as YAML by default but Papyrus supports defining it as either JSON or INI as well. Other flat-file CMS's, such as Pico, have opted to define the meta section by using three repeating dashes (---) which is how most static CMSs do it, so that's what's expected by default for compatibility's sake. However, you can just as well choose to define the section as a code block (```) which also allows you define a syntax highlighting (```yml). This was added for convenience sake in order to enable YAML highlighting in popular text-editors.

There are also a couple of expected keys:

  • title: This is not strictly required but is used as fallback in many cases, for example if "label" isn't defined on a menu item.
  • template: Template configures what template from the theme the page should be loaded with. By default the compiler looks for one named "page".
  • published: If missing the page is considered a draft and is hidden from feeds by default and content loaded via ContentManager and its helper functions.
  • language: If missing the language will be set to what's defined in the content config.

Page properties

The Papyrus\Content\Page class tries to only load data on-demand since a flat-file CMS application can otherwise get quite resource intensive if all pages are processed and kept in memory.

All properties and meta can be accessed using array access as well as tested with the methods has() and is(), which performs empty and boolean tests respectively. One can also call a method prepended with the test method names like $page->hasTitle() to check whether the meta property title is empty or not. Here's a list of what properties are set by default (refer to the advanced documentation for how to add custom properties):

  • relativePath (string) - The file path relative from the content directory: blog/index.md
  • id (string) - The unique identifier for the page: /blog/index
  • route (string) - Id but without "index": /blog/
  • hashid (string) - MD5 hash of the id: 7e1889003e5094afa723d24be7ce1357
  • url (string) - Absolute URL to the page: http://example.com/blog/
  • meta (Soma\Store) - All meta properties can also be accessed directly from Page object
  • html (string) - The rendered page contents
  • title (string) - Either the meta property or the filename if it's missing
  • keywords (array): Meta property or the top 10 keywords found from analyzing the content using RakePlus, unfortunately the library only supports the following language as of yet: en-US, es-AR, fr-FR, pl-PL, ru-RU
  • excerpt (string) - Meta property or the first paragraph of the page text
  • author (string) - Meta property or the config key content.author
  • updated (DateTime) - Meta property or the file's modification time
  • modified (DateTime) - The file's modification time
  • language (DateTime) - Meta property or the config key content.lang
  • draft (bool) - Whether the file is considered a draft or not, refer to the documentation further down for more information
  • image (Papyrus\Content\Image) - Meta property or the first image found in the rendered contents
  • images (array of Papyrus\Content\Image) - All images found in the rendered contents
  • raw (string) - The unprocessed file contents
  • bare (string) - The rendered page contents stripped from all HTML tags
  • body (string) - The unprocessed body section of the file

The following are also inherited from Papyrus\Content\File:

  • filename (string) - Just the basename without the file extension: index
  • basename (string) - The basename: index.md
  • path (string) - The full file path: /path/to/content/blog/index.md
  • extension (string) - The file extension: md
  • directory (string) - The directory containing the file: /path/to/content/blog

Since the Page class can be extended with additional features one might need to inspect an instance using the tinker command to get a more correct representation of the properties available.

php appctrl app:tinker

And then run get_page('index'); – or alternatively export and dump it on a web request: dd(get_page('index')->export()).

Filters

Some filters provide essential functionality while others merely add additional nice-to-haves. Custom ones can easily be created as well and the process is described in the advanced documentation.

anchors

The anchor filter process links to other pages and static content and makes sure the URLs are set correctly.

images

The filter processes all images and creates resized versions of each in order to be able to present them responsively to different devices.

includes

The includes filter append or prepend the markdown or rendered html with user specified content. Both the markdown and html keys support both before and after:

return [
    // ...
    'includes' => [
        'markdown' => [
            'after' => '/signature.md',
        ],
        'html' => [
            'before' => '/ads/advertisement.html'
        ],
    ],
    // ...
];

hashtags

The hashtags filter searches the page contents for hashtags (e.g. #yolo) and wraps with an anchor the tag ("yolo") prepended with a user defined route:

return [
    // ...
    'tag-route' => '/tag?t=',
    // ...
];

heading-id

Processes all the headings found in the page content and adds slugified ids.

heading-offset

Processes all the page contents and sets what the topmost heading type should be in a page. So if you write your content using h1 for your title and heading-offset is set to 2 then that heading will be converted into a h3 and all subheadings will be converted respectively. This is so that themes that display content in different ways and hierarchy can still produce correct HTML. The configuration key should be set by the theme in its manifest:

heading-offset: 2

shortcodes

Enable the processing of shortcodes in the page markdown.

smartypants

SmartyPants is a library that translates plain ASCII punctuation characters into "smart" typographic punctuation HTML entities. It makes the typography of the rendered page contents more professional. There are edge-cases where the library doesn't work as it should, so if you find you're having trouble with how the pages render you may want to try turning this one off.

One important thing to keep in mind is that a triple dash (---) is normally interpreted as an


but SmartyPants interprets double dash as an en-dash (–), therefore it's better to write hr's with a triple underscore (___) if using this filter.

Routing

There is no need to define any routes for Papyrus since it uses the filesystem to create the site hierarchy. A file named contact.md at the content root will be presented at /contact and a file in writings/ called my-first-essay.md will be presented at /writings/my-first-essay. Any directory containing a file called index.md will be presented at the root of that directory (/). So if you want to create different content types you can simply create a folder called "blog", create your blog posts as sub-resources and an index file as an archive.

-- blog/
-- -- 201907/
-- -- -- image.jpg
-- -- -- my-first-post.md
-- -- index.md
-- index.md
-- about.md
-- contact.md

A file missing the published meta key will be treated as a draft and is not viewable unless drafts are enabled and ?preview=true is added to the URL. If you prepend the file name with an underscore it will also be treated as a draft, e.g. _my-first-post.md, or if you set the meta property draft: true. If you have an underscored file with otherwise the same name as another file in the same folder it will be the unpublished draft of the live file. This is helpful since it allows you to work on a new revision of a file, and preview it, without accidentally publishing unfinished work. The draft is only presented if ?preview=true otherwise the original is presented:

-- blog/
-- -- 201907/
-- -- -- image.jpg
-- -- -- _my-first-post.md
-- -- -- my-first-post.md
-- -- index.md

If published is set to a future date it won't be considered published until the point in time has passed. You can also prepend folder names with an underscore to hide all content within. This is however not previewable:

-- blog/
-- -- _unpublished-drafts/
-- -- -- not-previewable.md

The file name "default.md" is reserved by the system as it's used as a template when programmatically creating new pages in the same directory.

Images

Papyrus provides a system to automatically process all images and linked files within a page's content. Absolute URLs are kept as is but local images within the content directory gets resized and processed to save bandwidth and enable presenting the most suitable size for each device. Links to other pages gets their proper absolute URL and PDF:s and other static gets copied to a public directory so that they can be downloaded.

Papyrus\Content\Image can load images from either remotely or from a theme or content directory and they will be processed and moved to a public cache directory. On string conversion it turns into a HTML img tag but one can also change attributes by rendering it via the display method.

image('my-header.jpg')->display(['alt' => 'My Header']);

Feed

You can easily define what content should be available in the site's main RSS feed using the include and exclude config keys. One can also define specific sub-feeds by setting the feed meta key: feed: Pottery. This will be available as a sub-resource: http://www.example.com/feed/pottery.

The system has been designed with podcasting in mind, to be able to attach media resources to feed items, but as of now it's not yet implemented. Please express if you have a need for it and the feature will prioritized.

Menus

Menus are also created using YAML and resolved using dot-notation syntax. So if you want to load a menu at menus/main.yml you call get_menu('menus.main'). You can also create a master file in the content root called menus.yml and define multiple entries in this one file and access them as if each is a file in the content root:

main:
    # ...
sidebar:
    # ... 

The menu format is as follows. For each item you can either reference the file under page or manually specifying a url. Any unsupported key will be available for you as a key on each item, so you can easily define icons or any other property directly here in the file.

A page key will be resolved into the following: url to the page, the route, and the label will be set to the page title unless label is already defined on the item.

Any item can have the key children set which creates a hierarchy of items. Other ways to do so is by setting list and it will resolve a query for all pages under that route and populate children. When using list you can also set sort and order, as well as the depth that you want the query to process and if you want the result presented as a flat single level list rather than a hierarchy. One can also limit lists to only return a set number of items.

Here is an example of what a menu file could look like:

main.yml

- page: /
  label: Home
- page: /about.md
- page: /contact.md
- url: "http://github.com/john-doe"
  label: "My GitHub"
- list: /blog/
  depth: 2
  flat: true
  sort: published
  order: desc
  limit: 10
  label: "Recent blog posts"
- label: "My other writings"
    children:
      - list: /essays/
        label: Essays
      - url: "http://example.com"
        label: "My other site"

Themes

Perhaps controversially it's encouraged to include logic in the theme files and they can be installed, along with their dependencies, via composer. The theming engine is using a Blade compiler by default and thereby allowing for heavy use of logic in the templates. The target audience of this project is primarily developers so instead of having to create custom plugins for minor and/or major features, everything can easily baked into the theme so that there's only one project the developer needs to worry about. One might prefer separating out the logic into a separate project and that can be done easily by providing them through a service.

A typical theme would look something like this:

MyTheme/
-- assets/
-- -- images/
-- -- -- logo.png
-- -- js/
-- -- -- main.js
-- -- scss/
-- -- -- main.scss
-- dist/
-- -- main.2ebbcecc.css
-- -- main.2f2121f2.js
-- -- logo.png
-- layouts/
-- -- bade.blade.php
-- lib/
-- -- MyThemeServiceProvider.php
-- 404.blade.php
-- contact.blade.php
-- index.blade.php
-- page.blade.php
-- post.blade.php
-- search.blade.php
-- tag.blade.php
-- composer.json
-- package.json
-- theme.yml

The only variables that gets set on a request and are available to the theme files are $request, an instance of Symfony\Component\HttpFoundation\Request for the current request, and $page, an instance of Papyrus\Content\Page for the current page. Everything else, all pages and menus, can be retrieved using simple helper functions or facades.

You can either create a theme from the boilerplate or create everything from scratch. The theme.yml in the root of your theme defines where Papyrus can find the resources provided:

name: MyTheme
inherit: OtherTheme
engine: blade
public: ./dist
templates: ./
images:
  filters: ['\MyTheme\ImageFilter']
  sizes: ['576', '768', '992', '1200']
  rules: ['(max-width: 576px) 576px', '(max-width: 768px) 768px', '992px']
heading-offset: 2

The active theme is set by the config key themes.active and changing the theme requires running a app:refresh to update the filesystem links:

php appctrl app:refresh

Properties

inherit

The theme can inherit other themes and the parent themes will be searched recursively to find the requested template or asset. You can specify a specific theme's asset when including a file via the theme_url helper:

$parentCss = theme_url('main.css', 'ParentTheme');

engine

engine defines what compiler engine the theme is designed for. By default blade and standard are provided, the latter is simply including PHP or HTML files. You can easily add support for a custom template engine like Twig via a service provider.

public

public will be linked to the public directory so that those files are made available. The active theme is set in config/themes.php and when it's changed you must run php appctrl app:refresh.

templates

The key templates is where the compiler engine will look for the templates requested. The default template that is selected when no template can be found is page so make sure to always define a page.blade.php template in your theme.

images

Papyrus automatically creates resized version of images you link to within your markdown files and generates responsive HTML for it. You can under images define either a custom rule that gets mapped to the attribute sizes or define an array of sizes that each image is resized to (rules => sizes, sizes => srcset). Callable filters with the following definition can also be set:

function(\Papyrus\Content\Image $image) {

    // ...

    return $image;
}

heading-offset

It's a key used by the heading-offset filter and sets what the topmost heading type will be in a page. So if you write your content using h1 for your title and heading-offset is set to 2 then that heading will be converted into a h3 and all subheadings will be converted respectively. This is so that themes that display content in different ways and hierarchy can still produce correct HTML.

Starter theme

There's an MIT licensed Papyrus starter theme that implements a simple Bootstrap 4 layout and showcasing most features on which you can build your own.

Commands

Most commands available by default are provided by SOMA so please refer to its documentation for more information.

content:compile

php appctrl content:compile {page?} {--force}

A page can be specified using a content directory relative path or if none are provided then all files in the content directory will be cache validated and recompiled if required.

The force flag forces a recompilation.

content:routes

php appctrl content:routes

The command presents a formatted list of all publicy available route end-points. Route handles that are reserved by an external handler are marked with an appended ellipsis.

Advanced

Services

You can utilize the full functionality of SOMA and the rest of the PHP ecosystem to customize your installation. Look for other pre-made services that integrate new functionality into Papyrus or make your own. Refer to the SOMA documentation for more information.

Shortcodes

Shortcodes are integrated using maiorano84/shortcodes and is a simple way to create custom tags for your markdown files. Define the logic in a service provider that you register:

namespace MyTheme;

use Soma\ServiceProvider;
use Psr\Container\ContainerInterface;

use Papyrus\ShortcodeManager;
use Papyrus\Content\Shortcode;
use Papyrus\Content\Page;

class MyThemeProvider extends ServiceProvider
{
    public function getExtensions() : array
    {
        return [
            'content.shortcodes' => function(ShortcodeManager $manager, ContainerInterface $c) {
                $manager->register(new Shortcode('gallery', ['type'=>'masonry'], function($content = null, array $atts = []) {
                    // The attribute markdown="1" enables
                    // markdown processing inside a HTML tag
                    return "## My ".$atts['type']." Gallery \n\n".$content;
                }));

                return $manager;
            },
        ];
    }
}

This shortcode will then be available to use in your page files:

[gallery type=slideshow]
![Me at the beach]('me-at-the-beach.jpg')
![My dog at the beach]('dog-at-the-beach.jpg')
[/gallery]

Routing

The built-in router supports custom route handlers that can either be defined in config/content.php or added programmatically in a service provider. They only need to callable and match the expected defintion. Make sure to return a boolean indicating if the request was handled by your code or not. If you want to create a class handling requests you can utilize the __invoke magic method.

return [
    'title' => 'My Site',
    'language' => 'en_US',
    'router' => [
        'disable' => false,
        'handlers' => [
            '/custom' => function (string $uri, \Symfony\Component\HttpFoundation\Request $request) {

                // ...

                return true;
            },
        ],
    ],
];

Adding Page features

Any customization of the Page class should preferable be done as an extension of the Filesystem class so that everything is applied before any actual page processing takes place. One can register custom filters automatically if a theme or service depends on it and the properties and methods are added via macros. All of the default macros and filters can be overloaded.

Pre-loaded and lazy-loaded properties

Properties and methods can be added and made available on the Page class using macros. There are three types of macros, each with their own definition. The first is simply a method that can be called on the Page instance (e.g. $page->greetPerson('John')) and may have any number of parameters it may require:

Page::macro('greetPerson', function($person) {
    return 'Hello '.$person;
});

The second type are pre-loaded properties. These are static in relation to the content and are saved as meta properties but requires some sort of processing of said content to be set. To indicate that the property is pre-loaded the method name should be prefixed with an underscore and the function will be passed $html and $meta since most properties aren't defined yet and trying to access them would cause a compilation recursion. One example is the property $page->excerpt that is included by default that either extracts the first content paragraph or the original meta property if it has been set:

Page::macro('_excerpt', function($html, $meta) {
    if ($excerpt = $meta->get('excerpt')) {
        $excerpt = Parsedown::instance()->text($excerpt);
    }
    else {
        $first = strpos($html, '') - $first);
        $excerpt = substr($excerpt, strpos($excerpt, '>') + 1);
    }

    return $excerpt;
});

The third type are lazy-loaded properties. These two are static in relation to the content since they will be cached for the duration of the request upon access but may return any type of instanced object since it won't be serialized and saved to file. To indicate that the property is lazy-loaded the method name should be suffixed with an underscore. This is the definition of $page->images that retrieves all the image files from the content body. Note how here it's safe to access the HTML using $this->html.

Page::macro('images_', function() {
    $dom = new DOMDocument();
    @ $dom->loadHTML('
'.$this->html.'
', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); $imgs = $dom->getElementsByTagName('img'); $images = []; if (! empty($imgs)) { foreach ($imgs as $key => $img) { try { $src = $img->getAttribute('src'); $images[] = new Image($img->getAttribute('src'), [ 'alt' => $img->getAttribute('alt'), 'sizes' => $img->getAttribute('sizes'), 'srcset' => $img->getAttribute('srcset'), 'class' => $img->getAttribute('class'), 'id' => $img->getAttribute('id'), 'data' => ['size' => $img->getAttribute('data-size')], ]); } catch (Exception $e) {} } } return $images; });

Mixins

Mixins are classes that will be processed using reflection to register macros. This makes it easier to register a bunch of related functionality in one go and keeps your code more neatly organized. Since the scope can't be changed from one class to another in PHP, and $this should point to the Page instance just like an internal method, one must return a function from the method that will be registered with the method name:

namespace MyTheme;

class MyMixin
{
    public function _preloadedProperty()
    {
        return function($html, $meta) {

            // ...

            return $preloaded;
        };
    }

    public function lazyLoadedProperty_()
    {
        return function() {

            // ...

            return $lazyLoaded;
        };
    }

    public function normalClassMethod()
    {
        return function ($param) {
            
            // ...

            return $result;
        };
    }
}

Custom content filters

Upon page compilation the markdown and then the html gets passed through all of the configured filters. Since files only are compiled upon updates and won't run on each request these can do more performance intensive operations without impacting live site peformance.

A filter can change the markdown prior to rendering, add or modify meta properties, and also process the resulting html after rendering. It must extend the Papyrus\Content\Filter abstract class.

namespace MyTheme;

use SplFileInfo;
use Soma\Store;
use Papyrus\Content\Filter;

class MyFilter extends Filter
{
    public function before(string $markdown, Store &$meta, ?SplFileInfo $file)
    {
        // ..

        return $markdown;
    }

    public function after(string $html, Store &$meta, ?SplFileInfo $file)
    {
        // ...

        return $html;
    }
}

Filters can also be run on strings if required, much like the Wordpress do_shortcode function call:

$html = filter_content('Random **markdown** formatted string with a [shortcode][/shortcode]');

Registering these using a Service Provider

To ensure that all modifications are applied to Page before any content processing happens you should do so as an extension of the Filesystem class.

namespace MyTheme;

use Soma\ServiceProvider;
use Psr\Container\ContainerInterface;

use Papyrus\Content\Page;
use Papyrus\Content\Filesystem;

use MyTheme\MyMixin;
use MyTheme\MyFilter;

class MyThemeProvider extends ServiceProvider
{
    public function getExtensions() : array
    {
        return [
            'content.filesystem' => function(Filesystem $files, ContainerInterface $c) {
                // Register macros using a mixin
                Page::mixin(new MyMixin());

                // Register a filter
                Page::addFilter('my-filter', new MyFilter());
                
                return $files;
            },
        ];
    }
}

Performance

The system is designed to only load the pages absolutely necessary to present the current request. If no other pages are presented (not presenting a directory index), then only a single page will be cache validated and loaded into memory, and pages are only ever queried once. So getting the same pages in multiple locations doesn't impact filesystem or memory usage.

In special cases, like when using the Search class to perform a full-text search, since only a subset of the total number of pages are what actually will be presented, the pages that won't be a part of the final result set gets unloaded.

In addition to this, page meta and body are cached separately and loaded on demand. So iterating through pages and only displaying their titles will never load the HTML into memory. Since some properties need to process the file content to be set and returns a dynamic object Papyrus implements lazy-loading of such properties that are static in relation to file content but accessed using an instanced class.

By default every page file that is new or updated will be recompiled on a request. That means that if the cache is cleared someone may experience a great performance hit on the next request, but since the cache always gets validated there shouldn't be a need to ever clear the cache. One way to mitigate this would be clearing the cache from a terminal command and then at the same time recompile everything in one go.

The cache behavior can be adjusted as well. The config key content.cache.ignore-mtime disables checking the modification date of the file and modifies the system to only compile a page if the cache file is missing completely. To have the website still reflect content changes one would have to schedule a scan to periodically validate the cache and recompile a file if invalid. A command is provided to support this:

php appctrl content:compile

The config key content.cache.validate-visited modifies that behavior to still validate the page being requested, so files other than the specific one requested, like in an archive, won't be recompiled until they are directly visited. Using these two keys in combination is the default and recommended configuration:

return [
    // ...
    'cache' => [
        'ignore-mtime' => true,
        'validate-visited' => true,
    ],
    // ...
];

You can also configure an included full-page cache or set the correct headers for an HTTP gateway caching mechanism. So if you notice specific pages receiving more traffic you can choose to cache these specifically. When using this type of system one need to be careful to not cache content that is dynamic and dependent on a user session. Refer to the configuration documentation for more information.

Another way to cache content on a presentation level is by installing a cache system in your theme (such as nsrosenqvist/soma-cache) and cache search results and archives so that it's not reloaded on each pagination.

API

The API is still largely undocumented, it's a work in progress. However, Papyrus is a small project so it's quite easy to just refer to the project source if you need to look up how to use any of the classes.

Systems

Papyrus is organized into two subsystems, themes and content. When you register Papyrus\Providers\PapyrusProvider it also adds Papyrus\Providers\ContentProvider and Papyrus\Providers\ThemesProvider as well as the default SOMA event system. The PapyrusProvider and the Papyrus\Papyrus class simply connects the two independent systems and resolves the current request.

Managers

The ShortcodeManager is just a wrapper around maiorano84/shortcodes and all its features can be utilized by ContentManager. Almost everything is represented by a class in Papyrus and by using the ContentManager to load a menu or a page rather than simply creating an instance on your own you will have all system configuration and caching applied and handled for you automatically.

The helpers are the recommended way for theme developers to interface with the ContentManager since as a developer you won't have to reference the fully namespaced class name or require the user to alias the class.

The ThemeManager is utilized by Papyrus to render the response for the web request and would rarely, or if ever, need to be used by anyone manually. Instead there are helpers available and changing the active theme is done using a config file.

Roadmap

There is no official project roadmap as I'm mostly scratching my own itch with this project and implement whatever I personally am in need of and have time for. However, here's a list of ideas that I'm considering for the future.

  • Form shortcode - A form shortcode registered by default to enable easy inclusion of forms in pages. The forms would be defined using YAML and support submission through either a post request (to for example a Google Docs form) or by email to a configurable list of recipients.
  • Podcasting - The system has been designed with podcasting in mind, to be able to attach media resources to feed items, but as of now it's not yet implemented. Please express if you have a need for it and the feature will prioritized.
  • Admin GUI - I already have a proof of concept admin interface package that add a REST API and is built using Vue.js. However I have no personal need of this at the moment so I'm not actively developing it.
  • Virtual Filesystem - Adding a virtual filesystem as a middle layer to minimize filesystem calls could provide significant performance increase when performing operations such as full-text search. However, doing so would require a significant rewrite of how the Page class loads and validates content as well as for how the Search class processes the pages, especially if both modes of operation should remain supported. Using such a system would likely require a periodical scheduled scan of the content directory to look for any new or updated files and that might warrant supporting both. Personally I don't suffer from any performance issues and I can't afford to spend time on a feature that I'm not sure would be necessary, but I'd be willing to prioritize this if a need is expressed.

If you have any ideas of your own or are willing to work on any of the above, please get involved via the issue tracker.

Helpers

The file helpers.php contain a couple of functions that are meant to simplify either calling app services or work with certain types of data. Please also refer to the helpers provided by SOMA.

Papyrus


response : \Symfony\Component\HttpFoundation\Response

Simply create an HTTP response

Parameters
  • integer - [$code]
  • string - [$content]
  • array - [$headers]
function response(int $code = 200, string $content = '', array $headers = []) : \Symfony\Component\HttpFoundation\Response
{
    $headers['content-type'] = $headers['content-type'] ?? 'text/plain';
    $content = (is_string($content)) ? $content : json_encode($content);

    return new \Symfony\Component\HttpFoundation\Response($content, $code, $headers);
}

request : \Symfony\Component\HttpFoundation\Request

Return the current request object

function request() : \Symfony\Component\HttpFoundation\Request
{
    return app('content.request');
}

current_url : string

Alias for request_url

function current_url() : string
{
    return request_url();
}

request_url : string

Return the current request's base URL and request URI

function request_url() : string
{
    $request = app('content.request');

    return $request->getSchemeAndHttpHost().$request->getBaseUrl().$request->getRequestUri();
}

request_vars : mixed

Return the value of an HTTP parameter sent using any method

Parameters
  • string - $key
  • mixed - [$default]
function request_vars(string $key, $default = null)
{
    return app('content.request')->get($key, $default);
}

request_body : string|resource

Return the request body content or a resource to read the body stream

function request_body()
{
    return app('content.request')->getContent();
}

request_uri : string

Get the current request's URI

function request_uri()
{
    return app('content.request')->getRequestUri();
}

request_scheme : string

Get the current request's scheme

function request_scheme()
{
    return app('content.request')->getScheme();
}

request_host : string

Get the current request's host

function request_host()
{
    return app('content.request')->getHttpHost();
}

image : \Papyrus\Content\Image|null

Create a \Papyrus\Content\Image instance from a filesystem resource

Both remote and local files will be created as an Image instance and local files will first be queried from within the theme and if not found then from the content directory.

Parameters
  • string|SplFileInfo - $path - The filesystem location of the resource
  • array - [$attr] - Optional override of image attributes
function image($path, array $attr = []) : ?\Papyrus\Content\Image
{
    if ($path instanceof \Papyrus\Content\Image) {
        return $path;
    }
    if ($path instanceof \SplFileInfo) {
        $path = $path->getPathname();
    }

    try {
        if (is_string($path)) {
            if (is_url($path)) {
                return new \Papyrus\Content\Image($path, $attr);
            }
            if (file_exists($themePath = theme_path($path))) {
                return new \Papyrus\Content\Image($themePath, $attr);
            }
            if (file_exists($contentPath = content_path($path))) {
                return new \Papyrus\Content\Image($contentPath, $attr);
            }
        }
    }
    catch (\Exception $e) {}

    return null;
}

content_path : string

Get the full path to the content folder or a resource within

Parameters
  • string - [$path] - Either empty or a relative path to a resource within the content directory
function content_path(string $path = '')
{
    return get_path('content').($path ? '/'.$path : $path);
}

theme_path : string

Get the full path to the active theme's folder or a resource within

Parameters
  • string - [$path] - Either empty or a relative path to a resource within the theme's directory
  • string|\Papyrus\Theme - $theme - A specific theme to look within
function theme_path(string $path = '', $theme = null)
{
    return app('themes')->path($path, $theme);
}

theme_url : string

Get the full URL to the active theme's folder or a resource within

Parameters
  • string - [$url] - Either empty or a relative path to a resource within the theme's directory
  • string|\Papyrus\Theme - $theme - A specific theme to look within
function theme_url(string $url = '', $theme = null)
{
    return app('themes')->url($url, $theme);
}

get_page : \Papyrus\Content\Page|null

Get a \Papyrus\Content\Page from the content directory

Parameters
  • string - $id - Can be provided as a route or relative path
  • boolean - [$drafts] - Determines whether drafts should be considered
  • boolean - [$draftsOnly]
function get_page(string $id, bool $drafts = false, bool $draftsOnly = false) : ?\Papyrus\Content\Page
{
    return app('content')->get($id, $drafts, $draftsOnly);
}

query_pages : \Illuminate\Support\Collection

Run a query against the filesystem

Rather than just querying for a single file one can specify a directory and get all the files within.

Parameters
  • string - $query - Relative path or route
  • integer - [$depth] - 0 ignores depth but a positive value limits the depth to which the filesystem will be searched
  • boolean - [$drafts] - Determines whether drafts should be considered
function query_pages($query, $depth = 0, $drafts = false)
{
    return app('content.filesystem')->query($query, $depth, $drafts);
}

all_pages : \Illuminate\Support\Collection

Get all pages found within the content directory

Parameters
  • boolean - [$drafts] - Determines whether drafts should be considered
  • boolean - [$draftsOnly]
function all_pages($drafts = false, $draftsOnly = false)
{
    return app('content')->all($drafts, $draftsOnly);
}

Perform a full-text search of the provided terms

Parameters
  • string - $terms - The terms to search for
  • array|\Illuminate\Support\Collection - [$pages] - A subset of pages to search through
function search(string $terms, $pages = null) : \Papyrus\Content\Search
{
    return app('content')->search($terms, $pages);
}

paginate : \Papyrus\Content\Pagination

Paginate a set of pages

Parameters
  • array|\Illuminate\Support\Collection - [$pages] - An optional subset of pages to paginate
  • integer - [$limit] - Number of items per page
  • integer - [$page] - Current page number
function paginate($pages = null, $limit = null, $page = null) : \Papyrus\Content\Pagination
{
    return app('content')->paginate($pages, $limit, $page);
}

filter_content : string

Run string through the configured \Papyrus\Content\Page filters

Parameters
  • string - $content
  • array|\Soma\Store - [$meta]
  • SplFileInfo - [$file]
function filter_content(string $content, $meta = [], $file = null) : string
{
    return \Papyrys\Content\Page::filter($content, $meta, $file);
}

get_menu : \Papyrus\Content\Menu

Get a configured menu

If the menu doesn't exist an empty \Papyrus\Content\Menu instance will be created

Parameters
  • string - $id
function get_menu(string $id) : \Papyrus\Content\Menu
{
    return app('content')->menu($id);
}

get_feed : \Papyrus\Content\Feed

Get a \Papyrus\Content\Feed

Parameters
  • string - [$type] - Get specific feed type
function get_feed(?string $type = null) : \Papyrus\Content\Feed
{
    return app('content')->getFeed($type);
}

feed_url : string

Get a feed URL

Parameters
  • string - [$type] - Get specific feed type
function feed_url(?string $type = null) : string
{
    return app('content')->feedUrl($type);
}

markdown : string

Parse a markdown formatted string

If you want to parse a string according to the same rules as \Papyrus\Content\Page then you should rather use filter_content.

Parameters
  • string - $text
function markdown(string $text) : string
{
    return \ParsedownExtra::instance()->text($text);
}

strip_bare : string

Strip text completely bare from HTML and special characters

Parameters
  • string - $html
  • boolean - [$newLines] - By default newlines are stripped but this can be turned off
function strip_bare(string $html, bool $newLines = true) : string
{
    // Add a space to each tag before stripping them
    $html = str_replace('<', '="" <',="" $html);="" $string="strip_tags($html);" if="" ($newLines)="" {="" ',="" $string);="" }="" else="" \h+="" Remove="" special="" characters="" urls="" $regex="@(https?://([-\w\.]+[-\w])+(:\d+)?(/([\w/_\.#-]*(\?\S+)?[^\.\s])?)?)@" ;="" $bare="preg_replace($regex," return="" trim($bare);="" }<="" code="">