Migrating from Gryphon to CEO Gryphon Compatibility Layer

Gryphon Compatibility Layer seeks to provide a fully Gryphon compatible environment, but some changes are necessary to support updates the the underlying Twig templating system.

Luckily, there's a command to find all the issues listed below. Simply run the following and CEO will report any issues with your templates:

php run twig:lint path/to/templates

Things you must do

All of the following will cause errors in your template and should be fixed right away. Fortunately, these are all backward compatible so making a these changes before moving to CEO will not cause errors in the Gryphon site.

Macro inheritance changes in Twig 2.x

Macro inheritance was, apparently, a bug in Twig 1.x that has been "fixed" in Twig 2.x. That means you can no longer have a macro import in your base template and expect it to be available in the child templates. The macro must be imported into each scope that requires it.

BAD

base.tpl:
{% import "macros/variables.tpl" as variables %}
{% block title %}{% endblock %}
{% block content %}{% endblock %}

view.tpl:
{% extends "base.tpl" %}
{% block title %}
    {{ variables.getTitle() }}
{% endblock %}
{% block content %}
    {{ variables.getDescription() }}
{% endblock %}

GOOD

base.tpl:
{% block title %}{% endblock %}

view.tpl:
{% extends "base.tpl" %}
{% block title %}
    {% import "macros/variables.tpl" as variables %}
    {{ variables.getTitle() }}
{% endblock %}
{% block content %}
    {% import "macros/variables.tpl" as variables %}
    {{ variables.getDescription() }}
{% endblock %}

Escaping output

Twig 2.x no longer automatically escapes HTML for you, that means any place in your template that outputs raw HTML must be escaped with the raw filter.

BAD

{{ article.content_formatted }}

GOOD

{{ article.content_formatted|raw }}

Fetching with uid

When fetching with uid, be sure to namespace it with self: to avoid confusion. The primary key field is ambiguous and can cause errors when selecting content.

BAD

{% fetch articles from article with {
    'where': 'uid = 1',
    'limit': 1
} %}

GOOD

{% fetch articles from article with {
    'where': 'self:uid = 1',
    'limit': 1
} %}

uid should always be lowercase

CEO is case-sensitive, and calls to uid should always be lowercase as internally, the Gryphon Compatibility Layer converts uid to the CEO model's internal primary key, which may be different.

BAD

{% fetch articles from article with {
    'where': 'UID = 1',
    'limit': 1
} %}

GOOD

{% fetch articles from article with {
    'where': 'self:uid = 1',
    'limit': 1
} %}

Some filters have been removed

  • random
    You need to use the random function instead. For example:
    {{ '0'|random }} becomes {{ random() }}
  • toTime
    Use the date and date_modify filters instead. For example:
    {{ "-1 day"|toTime }} becomes {{ 'now'|date_modify('-1 day')|date('u') }}

Server side browser sniffing is gone

Look... it was bad, it's not a good idea to use it, and it never really worked properly in the first place. Here are some possible work arounds:

Desktop and mobile layouts

Media queries, yo. Plus, Bootstrap, since v3, has had push/pull and column sizing. There's really no reason not to.

Display different content based on device

See above.

Display same ad code in different places

You can actually do this, you just have to plan ahead. Let's say, you have the following ad tag in your header or GTM block:

    googletag.defineSlot('/1234/XXX_AdhesionsBanner_Mobile_320x50', [320, 50], 'div-gpt-ad-1234567890-0').addService(googletag.pubads());

And the ad code looks like this:

    <div id='div-gpt-ad-1234567890-0' style='width:320px; height:50px; margin: 0 auto;'>
    <script type='text/javascript'>
    googletag.cmd.push(function()
    { googletag.display('div-gpt-ad-1234567890-0'); }
    );
    </script>
    </div>

All you have to do is add another slot with a random number:

    googletag.defineSlot('/1234/XXX_AdhesionsBanner_Mobile_320x50', [320, 50], 'div-gpt-ad-1234567890-0').addService(googletag.pubads());
    googletag.defineSlot('/1234/XXX_AdhesionsBanner_Mobile_320x50', [320, 50], 'div-gpt-ad-1234567890-2').addService(googletag.pubads());

Then you'll be able to use another ad tag with that id:

    <div id='div-gpt-ad-1234567890-2' style='width:320px; height:50px; margin: 0 auto;'>
    <script type='text/javascript'>
    googletag.cmd.push(function()
    { googletag.display('div-gpt-ad-1234567890-2'); }
    );
    </script>
    </div>

Things you should do

The following items should be done to make the transition away from the Compatibility Layer easier in the future.

Macro scope

Like inserting the macro imports into each scope they're required, it's also a good idea to remove any imports that aren't being called anywhere. The lint command will warn you of any unused imports.


Base meta

Sites should switch to the bundled CEO meta template, instead of using their own. This will eensure maximum compatibility with social media services. It is usually as simple as substituting the site's meta properties for the following:

{% include 'helpers/meta.twig' %}

Basic variables

If you do use the bundled meta template, you need to ensure the variables macro has the correct macros defined. The lint command will warn you if any are missing.


Search Results

Sites should implement the bundled search results template, unless there is a good reason not to. This is as simple as adding the following to the search/advanced.tpl, in place of the current result list:

{% include helpers/search-results.twig %}

Spaces not tabs

Srsly. Don't use tabs. Luckily for you, we have a command to convert your files:

php run twig:detab --tab-stop=4 path/to/templates

Migrating view callbacks

The one item the Gryphon Compatibility Layer doesn't provide a direct upgrade path for is custom view callbacks. These customizations, which live in the site's template/gryphon/view folder will need to be converted to CEO Interceptors.

Luckily 99% of sites do not use any custom view callbacks, and those that do use a standard set. Below are a number of common customizations found in Gryphon view callbacks and how to translate them to Ceo Interceptors.

Breaking news

Note: this has been done in the default IndexInterceptor, but if you need it for other pages, here is how to handle breaking news.

To handle loading breaking news, you'll likely see something like this in the view/main.view.php callback:

function main($container, $payload, $kwargs=array()) {
    ...

    // check for breaking news
    $payload['breaking'] = false;

    $breaking = M::init('article')
        ->cache(false)
        ->where('self:status = 1')
        ->order('self:created desc')
        ->limit(1)
        ->findByTags(M::init('tag')->findByName('breaking'))
        ->pop();

    if( $breaking && $breaking->uid ) {
        $payload['breaking'] = $breaking;
    }

    ...
}

This can be replicated in an interceptor using the beforeRender method:

public function beforeRender($params = [])
{
    if (!isset($params['breaking'])) {
        $breaking = false;
        $tag = $this->getDI()->getTagManager()
            ->find(['name = "breaking"']);

        $breaking = $this->getDI()->getArticleManager()
            ->getBuilder()
            ->wherePublished()
            ->orderBy('published_at desc')
            ->limit(1)
            ->withTags($tag)
            ->find();

        if ($breaking) {
            // make sure to wrap the returned object in a compatibility model
            $params['breaking'] = new \Ceo\Compat\Model\Article($breaking[0]);
        }
    }

    return $params;
}

Multimedia selection and pagination

Gryphon handled YouTube, Vimeo and plain video differently, CEO does not. But, there may be cases where you need to override what is returned from the media controller and provide pagination back to the view.

A common example from Gryphon looks like this:

function main($container, $payload, $kwargs=array()) {
    $slug = $payload['slug'];

    if( $slug == 'video' ) {
        $id = false;
        $slug = false;

        $topMedia = false;

        $limit = 20;
        $start = 0;
        $page = $container["Request"]->get('page', 'num');

        if( $page && $page >= 0 ) {
            $start = $page * $limit;
        }

        if( !($id = $container["Request"]->get(':id', 'num')) ) {
            $id = $container["Request"]->get(':slug', 'num');
        }

        $slug = $container["Request"]->get(':slug', 'specialChars');

        $media = M::init('gryphon:media');
        if( !\admin\lib\auth::hasSession() ) {
            $media->where('self:status = 1');
        }

        $media = $media
            ->order('self:created desc')
            ->limit($start.', '.$limit);
        $tags = M::init('gryphon:tag')->findByName('multimedia');

        $media = $media
            ->where('self:type = "youtube" or self:type = "vimeo"')
            ->find();

        $url = 'gryphon:multimedia';
        if( $slug ) {
            $url .= '/'.$slug;
        }
        if( $id ) {
            $url .= '/'.$id;
        }

        $pag = new \foundry\model\paginator($media, $page, $limit, 5);
        $pag->setURL($url, array(
            'page' => '%PAGE%'
        ));

        if( $topMedia ) {
            $media->pop();
            $media->unshift($topMedia);
        }

        $payload['media'] = $media;
        $payload['pagination'] = $pag;
    }

    ...
}

In CEO, you could do the following:

public function beforeRender($params = [])
{
    if ($params['slug'] == 'video') {
        $page = $this->request->getQuery('page', 'int', 0);
        $perPage = $this->request->getQuery('per_page', 'int', 20);

        $tag = $this->getDI()->getTagManager()->findFirst([
            'name = "multimedia"'
        ]);

        $builder = $this->getDI()->getMediaManager()->getBuilder()
            ->where(["type in ('video', 'youtube', 'vimeo')"])
            ->setPage($page)
            ->setLimit($perPage)
            ->byTags([$tag]);

        $builder = $builder->paginate();

        $params['media'] = $builder->getItems();
        $params['pagination'] = $builder->getPagination();
    }
}

Dynamic loading

Many times the view callback is used to load a different template to handle infinite loading for a section. While in CEO you could just add another route and endpoint, you can still do this the old school way.

In Gryphon, in the site's section template, you might have a call like this:

// load section
$.get('/section/sports.html?ajax=1', ...)

In the view callback, you had something like:

function main($container, $payload, $kwargs=array()) {
    ...
    $post   = $container["Request"]->any('post', 'num');
    $template = false;
    if ($post) {
        $template = 'section/_dynamic_load.tpl';
    }
    ...

    try {
        $tpl = new Template($template);

        $res = new Response;
        $res->content = $tpl->render($payload);
    } catch( \foundry\exception $e ) {
        // couldn't locate template load the default

        $tpl = new Template('section/main.tpl');

        $res = new Response;
        $res->content = $tpl->render($payload);
    }

    return $res;
}

In CEO, after making sure you've set up the client's Module.php, override their section interceptor with a custom one, using their namespace (Abc in this case):

    'view'          => [
        ...
        'interceptors'  => [
            ...
            '/section/{slug}'                => '\Abc\Interceptors\SectionInterceptor',
            ...
        ]
    ]

Then add your new interceptor, using the beforeRender function:

templates/library/src/interceptors/SectionInterceptor.php:
<?php
namespace Abc\Interceptors;

use Ceo\Http\Response;

class SectionInterceptor extends \Ceo\Compat\Interceptors\SectionInterceptor
{
    public function beforeRender($params = [])
    {
        $params = parent::beforeRender($params);

        if ($this->request->getQuery('ajax')) {
            $template = 'gryphon/section/_dynamic_load.tpl';
            $partial = $this->getDI()->getTwigPartial();
            $content = $partial->render($template, $params);

            $resp = new Response;
            $resp->setContent($content);

            return $resp;
        }

        return $params;
    }
}

JSON feeds

Another common use of view callbacks is to insert missing data into JSON feeds, like author or media data. While this is handled for you in CEO, you may need to alter the format.

A typical JSON callback in Gryphon may look like this:

function json($request, $payload, $kwargs=array()) {
    for ($i=0; $i < $payload['articles']->length; $i++) {
        $payload['articles'][$i]->media;
        $payload['articles'][$i]->tags;
    }

    return \foundry\view\json($request, $payload, $kwargs);
}

In CEO, you could do the following:

public function beforeRenderJson($params = [])
{
    $articles = $params['articles']->map(function($article) {
        return $article->toArray([
            'related' => [
                'media',
                'tags',
                'authors'
            ]
        ]);
    });

    $response = new \Phalcon\Http\Response;
    $response->setHeader('Content-Type', 'application/json');
    $response->setHeader('E-Tag', md5($params['section']->modified_at));

    $response->setContent(json_encode($articles, JSON_PRETTY_PRINT));

    return $response;
}