Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Sulu - Leverage the power of a framework based CMS

Sulu - Leverage the power of a framework based CMS

Batteries included! Thanks to Symfony.

Thomas Schedler

September 07, 2017
Tweet

More Decks by Thomas Schedler

Other Decks in Programming

Transcript

  1. I installed this free plugin and
    now the whole website crashed!
    It can´t take that long to add this simple feature!
    Why is our website so slow?!
    Can we add a new
    language to the site by
    tomorrow?
    Sulu CMS
    Batteries included! Thanks to Symfony.

    View Slide

  2. I'm Thomas Schedler
    @chirimoya | https://github.com/chirimoya

    View Slide

  3. I'm Thomas Schedler
    @chirimoya | https://github.com/chirimoya
    ... head of development and
    technical consultant. Young
    father trying to master Heston
    Blumenthal recipes.

    View Slide

  4. Router
    The main entry point to your Symfony application.

    View Slide

  5. Request
    Response

    View Slide

  6. /
    /services
    /blog
    Request
    Response

    View Slide

  7. /
    /services
    /blog
    Request
    Response
    Front Controller

    View Slide

  8. /
    /services
    /blog
    Request
    Response
    Kernel
    Front Controller

    View Slide

  9. /
    /services
    /blog
    Request
    Response
    Kernel
    Router
    Request URI Controller & Action
    Front Controller

    View Slide

  10. /
    /services
    /blog
    Request
    Response
    Kernel Controller
    Router
    Request URI Controller & Action
    Front Controller

    View Slide

  11. /
    /services
    /blog
    Request
    Response
    Kernel Controller
    Router
    Request URI Controller & Action
    Front Controller
    indexAction()
    servicesAction()
    blogAction()

    View Slide

  12. /
    /services
    /blog
    Request
    Response
    Kernel Controller
    Router
    Request URI Controller & Action
    Front Controller
    indexAction()
    servicesAction()
    blogAction()
    Model
    View
    Services

    View Slide

  13. /
    /services
    /blog
    Request
    Response
    Kernel Controller
    Router
    Request URI Controller & Action
    Front Controller
    indexAction()
    servicesAction()
    blogAction()
    Response
    Response
    Response
    Model
    View
    Services

    View Slide

  14. Router
    /
    /services
    /blog
    Request
    Response
    Kernel Controller
    Request URI Controller & Action
    Front Controller
    indexAction()
    servicesAction()
    blogAction()
    Response
    Response
    Response
    Model
    View
    Services

    View Slide

  15. Router
    /
    /services
    /blog
    Request
    Response
    Kernel Controller
    Request URI Controller & Action
    Front Controller
    indexAction()
    servicesAction()
    blogAction()
    Response
    Response
    Response
    Model
    View
    Services

    View Slide

  16. Router
    Chain Router
    /
    /services
    /blog
    Request
    Response
    Kernel Controller
    Request URI Controller & Action
    Front Controller
    indexAction()
    servicesAction()
    blogAction()
    Response
    Response
    Response
    Model
    View
    Services

    View Slide

  17. Router
    Chain Router
    /
    /services
    /blog
    Request
    Response
    Kernel Controller
    Request URI Controller & Action
    Front Controller
    indexAction()
    servicesAction()
    blogAction()
    Response
    Response
    Response
    Model
    View
    Services

    View Slide

  18. Router
    Chain Router
    /
    /services
    /blog
    Request
    Response
    Kernel Controller
    Request URI Controller & Action
    Front Controller
    indexAction()
    servicesAction()
    blogAction()
    Response
    Response
    Response
    Model
    View
    Services
    Dynamic Router

    View Slide

  19. Sulu routing summarized

    View Slide

  20. Sulu routing summarized
    – CMF ChainRouter replaces the default
    routing system

    View Slide

  21. Sulu routing summarized
    – CMF ChainRouter replaces the default
    routing system
    – and works by accepting a set of
    prioritized Routers

    View Slide

  22. Sulu routing summarized
    – CMF ChainRouter replaces the default
    routing system
    – and works by accepting a set of
    prioritized Routers
    – The Symfony default Router is registered
    with the highest priority

    View Slide

  23. Sulu routing summarized
    – CMF ChainRouter replaces the default
    routing system
    – and works by accepting a set of
    prioritized Routers
    – The Symfony default Router is registered
    with the highest priority
    – DynamicRouters handle all the
    dynamically defined routes (pages,
    redirects, …)

    View Slide

  24. // app/WebsiteKernel.php


    class WebsiteKernel extends AbstractKernel

    {

    /**

    * {@inheritdoc}

    */

    protected $name = 'website';


    /**

    * @param string $environment

    * @param bool $debug

    */

    public function __construct($environment, $debug)

    {

    parent::__construct($environment, $debug);

    $this->setContext(self::CONTEXT_WEBSITE);

    }


    /**

    * {@inheritdoc}

    */

    public function registerBundles()

    {

    $bundles = parent::registerBundles();

    $bundles[] = new Symfony\Cmf\Bundle\RoutingBundle\CmfRoutingBundle();

    return $bundles;

    }

    }

    View Slide

  25. // app/WebsiteKernel.php


    class WebsiteKernel extends AbstractKernel

    {

    /**

    * {@inheritdoc}

    */

    protected $name = 'website';


    /**

    * @param string $environment

    * @param bool $debug

    */

    public function __construct($environment, $debug)

    {

    parent::__construct($environment, $debug);

    $this->setContext(self::CONTEXT_WEBSITE);

    }


    /**

    * {@inheritdoc}

    */

    public function registerBundles()

    {

    $bundles = parent::registerBundles();

    $bundles[] = new Symfony\Cmf\Bundle\RoutingBundle\CmfRoutingBundle();

    return $bundles;

    }

    }
    $bundles[] = new AppBundle\AppBundle();

    View Slide

  26. // app/config/website/routing.yml
    app:

    resource: "@AppBundle/Controller/"

    type: annotation

    prefix: /app

    View Slide

  27. // app/config/website/routing.yml
    app:

    resource: "@AppBundle/Controller/"

    type: annotation

    prefix: /app
    // src/AppBundle/Controller/DefaultController.php
    namespace AppBundle\Controller;
    use Symfony\Bundle\FrameworkBundle\Controller\Controller;
    use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
    class DefaultController extends Controller
    {
    /**
    * @Route("/")
    */
    public function indexAction()
    {
    return $this->render('AppBundle:Default:index.html.twig');
    }
    }

    View Slide

  28. // app/config/website/routing.yml
    app:

    resource: "@AppBundle/Controller/"

    type: annotation

    prefix: /app
    // src/AppBundle/Controller/DefaultController.php
    namespace AppBundle\Controller;
    use Symfony\Bundle\FrameworkBundle\Controller\Controller;
    use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
    class DefaultController extends Controller
    {
    /**
    * @Route("/")
    */
    public function indexAction()
    {
    return $this->render('AppBundle:Default:index.html.twig');
    }
    }
    // src/AppBundle/Resources/views/Default/index.html.twig
    Hallo World!

    View Slide

  29. View Slide

  30. Controller & View
    Add your custom logic within your own content Controller.

    View Slide

  31. Controller
    Chain Router
    Router
    /
    /services
    /blog
    Request
    Response
    Kernel
    Request URI Controller & Action
    Front Controller
    indexAction()
    servicesAction()
    blogAction()
    Response
    Response
    Response
    Model
    View
    Services
    Dynamic Router

    View Slide

  32. Chain Router
    Router
    /
    /services
    /blog
    Request
    Response
    Kernel
    Request URI Controller & Action
    Front Controller
    indexAction()
    servicesAction()
    blogAction()
    Response
    Response
    Response
    Model
    View
    Services
    Dynamic Router
    DefaultController

    View Slide

  33. // app/Resources/templates/pages/default.xml

    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"

    xsi:schemaLocation="http://schemas.sulu.io/template/template http://
    schemas.sulu.io/template/template-1.0.xsd">


    default



    2400



    Default

    Standard



    ...

    SuluWebsiteBundle:Default:index
    templates/default

    View Slide

  34. // app/Resources/templates/pages/default.xml

    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"

    xsi:schemaLocation="http://schemas.sulu.io/template/template http://
    schemas.sulu.io/template/template-1.0.xsd">


    default



    2400



    Default

    Standard



    ...

    AppBundle:Custom:index
    templates/default

    View Slide

  35. // app/Resources/templates/pages/default.xml

    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"

    xsi:schemaLocation="http://schemas.sulu.io/template/template http://
    schemas.sulu.io/template/template-1.0.xsd">


    default



    2400



    Default

    Standard



    ...

    AppBundle:Custom:index
    AppBundle:Custom:index

    View Slide

  36. // src/AppBundle/Controller/CustomController.php


    namespace AppBundle\Controller;


    use Sulu\Bundle\WebsiteBundle\Controller\WebsiteController;

    use Sulu\Component\Content\Compat\StructureInterface;


    class CustomController extends WebsiteController

    {

    /**

    * My custom controller action.

    *

    * @param StructureInterface $structure

    * @param bool $preview

    * @param bool $partial

    *

    * @return Response

    */

    public function indexAction(StructureInterface $structure, $preview = false, $partial = false)

    {

    $response = $this->renderStructure(

    $structure,

    [

    // here you can add some custom data for your template

    'myData' => $this->get('my_custom_service')->getMyData(),

    ],

    $preview,

    $partial

    );


    return $response;

    }

    }

    View Slide

  37. Response Format
    HTML, XML or JSON

    View Slide

  38. // app/Resources/templates/pages/default.xml

    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"

    xsi:schemaLocation="http://schemas.sulu.io/template/template http://schemas.sulu.io/template/template-1.0.xsd">


    default


    AppBundle:Custom:index

    AppBundle:Custom:index

    2400


    ...

    View Slide

  39. // app/Resources/templates/pages/default.xml

    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"

    xsi:schemaLocation="http://schemas.sulu.io/template/template http://schemas.sulu.io/template/template-1.0.xsd">


    default


    AppBundle:Custom:index

    AppBundle:Custom:index

    2400


    ...

    .html.twig

    View Slide

  40. // app/Resources/templates/pages/default.xml

    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"

    xsi:schemaLocation="http://schemas.sulu.io/template/template http://schemas.sulu.io/template/template-1.0.xsd">


    default


    AppBundle:Custom:index

    AppBundle:Custom:index

    2400


    ...

    // src/AppBundle/Resources/views/Custom/index.html.twig
    {% extends "master.html.twig" %}
    {% block content %}
    {{ content.title }}

    {{ content.article|raw }}

    {% endblock %}
    .html.twig

    View Slide

  41. // app/Resources/templates/pages/default.xml

    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"

    xsi:schemaLocation="http://schemas.sulu.io/template/template http://schemas.sulu.io/template/template-1.0.xsd">


    default


    AppBundle:Custom:index

    AppBundle:Custom:index

    2400


    ...

    // src/AppBundle/Resources/views/Custom/index.html.twig
    {% extends "master.html.twig" %}
    {% block content %}
    {{ content.title }}

    {{ content.article|raw }}

    {% endblock %}
    // src/AppBundle/Resources/views/Custom/index.json.twig
    {{ content|json_encode|raw }}
    .html.twig

    View Slide

  42. View Slide

  43. HTTP Cache & ESI
    HTTP Standards FTW!

    View Slide

  44. Reverse Proxy Caches

    View Slide

  45. Reverse Proxy Caches
    – A HTTP Cache is a full page cache

    View Slide

  46. Reverse Proxy Caches
    – A HTTP Cache is a full page cache
    – It bypasses your application entirely, if
    the cache entry is valid

    View Slide

  47. Reverse Proxy Caches
    – A HTTP Cache is a full page cache
    – It bypasses your application entirely, if
    the cache entry is valid
    – HTTP cache headers are used to mark a
    response cacheable and for how long

    View Slide

  48. Reverse Proxy Caches
    – A HTTP Cache is a full page cache
    – It bypasses your application entirely, if
    the cache entry is valid
    – HTTP cache headers are used to mark a
    response cacheable and for how long
    – Symfony comes with a reverse proxy
    written in PHP

    View Slide

  49. Reverse Proxy Caches
    – A HTTP Cache is a full page cache
    – It bypasses your application entirely, if
    the cache entry is valid
    – HTTP cache headers are used to mark a
    response cacheable and for how long
    – Symfony comes with a reverse proxy
    written in PHP
    – Switch to something more robust like
    Varnish without any problem

    View Slide

  50. Reverse Proxy Caches
    – A HTTP Cache is a full page cache
    – It bypasses your application entirely, if
    the cache entry is valid
    – HTTP cache headers are used to mark a
    response cacheable and for how long
    – Symfony comes with a reverse proxy
    written in PHP
    – Switch to something more robust like
    Varnish without any problem
    https://tomayko.com/blog/2008/things-caches-do

    View Slide

  51. Reverse Proxy Caches
    – A HTTP Cache is a full page cache
    – It bypasses your application entirely, if
    the cache entry is valid
    – HTTP cache headers are used to mark a
    response cacheable and for how long
    – Symfony comes with a reverse proxy
    written in PHP
    – Switch to something more robust like
    Varnish without any problem
    https://tomayko.com/blog/2008/things-caches-do

    View Slide


  52. Caching entire responses isn't always possible for
    highly dynamic sites, or is it?

    View Slide

  53. ESI - Edge Side Includes

    View Slide

  54. ESI - Edge Side Includes
    – The ESI specification describes tags to
    communicate with the gateway cache

    View Slide

  55. ESI - Edge Side Includes
    – The ESI specification describes tags to
    communicate with the gateway cache
    – In Symfony the is
    implemented

    View Slide

  56. ESI - Edge Side Includes
    – The ESI specification describes tags to
    communicate with the gateway cache
    – In Symfony the is
    implemented
    – If the response contains ESI tags, the
    cache either requests the page fragment
    from the backend or embeds the fresh
    cache entry

    View Slide

  57. // app/config/config.yml
    framework:

    ...
    esi: { enabled: true }

    View Slide

  58. // app/config/config.yml
    framework:

    ...
    esi: { enabled: true }
    // app/Resources/views/Default/index.html.twig
    {# you can use a controller reference #}
    {{ render_esi(controller('AppBundle:News:latest', { 'limit': 5 })) }}
    {# ... or a URL #}
    {{ render_esi(url('latest_news', { 'limit': 5 })) }}

    View Slide

  59. Model
    Customize what you need.

    View Slide

  60. Customizing Models

    View Slide

  61. Customizing Models
    – Doctrine doesn't support model
    customization

    View Slide

  62. Customizing Models
    – Doctrine doesn't support model
    customization
    – Inheritance leads to multiple tables for the
    same data structure

    View Slide

  63. Customizing Models
    – Doctrine doesn't support model
    customization
    – Inheritance leads to multiple tables for the
    same data structure
    – Sulu’s PersistenceBundle allows to replace
    models via configuration

    View Slide

  64. Customizing Models
    – Doctrine doesn't support model
    customization
    – Inheritance leads to multiple tables for the
    same data structure
    – Sulu’s PersistenceBundle allows to replace
    models via configuration
    – Inspired by Sylius ResourceBundle

    View Slide

  65. // src/AppBundle/EntityTag.php
    namespace AppBundle\Entity;
    use Sulu\Bundle\TagBundle\Entity\Tag as SuluTag;
    class Tag extends SuluTag
    {
    public $description;
    }

    View Slide

  66. // src/AppBundle/EntityTag.php
    namespace AppBundle\Entity;
    use Sulu\Bundle\TagBundle\Entity\Tag as SuluTag;
    class Tag extends SuluTag
    {
    public $description;
    }
    // src/AppBundle/Resources/config/doctrine/Tag.orm.xml

    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping http://doctrine-project.org/schemas
    orm/doctrine-mapping.xsd">




    View Slide

  67. // src/AppBundle/EntityTag.php
    namespace AppBundle\Entity;
    use Sulu\Bundle\TagBundle\Entity\Tag as SuluTag;
    class Tag extends SuluTag
    {
    public $description;
    }
    // src/AppBundle/Resources/config/doctrine/Tag.orm.xml

    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping http://doctrine-project.org/schemas
    orm/doctrine-mapping.xsd">




    // app/config/config.yml
    sulu_tag:
    objects:
    tag:
    model: AppBundle\Entity\Tag

    View Slide

  68. Event Dispatcher
    Handle additional business logic within your Event Subscriber.

    View Slide

  69. View Slide

  70. Symfony Events

    View Slide

  71. Symfony Events
    Kernel Events
    – kernel.request
    – kernel.response
    – kernel.controller
    – kernel.view
    – kernel.terminate

    View Slide

  72. Symfony Events
    Kernel Events
    – kernel.request
    – kernel.response
    – kernel.controller
    – kernel.view
    – kernel.terminate

    Doctrine Events

    View Slide

  73. Symfony Events
    Kernel Events
    – kernel.request
    – kernel.response
    – kernel.controller
    – kernel.view
    – kernel.terminate

    Doctrine Events
    Lifecycle Events
    – [pre|post]Remove
    – [pre|post]Persist
    – [pre|post]Update
    – [pre|on|post]Flush
    – onClear

    View Slide

  74. View Slide

  75. Sulu Document Manager Events

    View Slide

  76. Sulu Document Manager Events
    bin/adminconsole sulu:document:subscriber:debug

    View Slide

  77. Sulu Document Manager Events
    +----------------------+
    | Events |
    +----------------------+
    | persist |
    | hydrate |
    | remove |
    | refresh |
    | copy |
    | move |
    | create |
    | clear |
    | find |
    | reorder |
    | publish |
    | unpublish |
    | remove_draft |
    | flush |
    | query.create |
    | query.create_builder |
    | query.execute |
    | configure_options |
    | metadata_load |
    | restore |
    +----------------------+
    bin/adminconsole sulu:document:subscriber:debug

    View Slide

  78. // src/AppBundle/Document/Subscriber/MailSubscriber.php


    namespace AppBundle\Document\Subscriber;


    use Sulu\Component\DocumentManager\Event\PublishEvent;

    use Sulu\Component\DocumentManager\Events;

    use Symfony\Component\EventDispatcher\EventSubscriberInterface;


    class MailSubscriber implements EventSubscriberInterface

    {

    ...


    /**

    * {@inheritdoc}

    */

    public static function getSubscribedEvents()

    {

    return [

    Events::PUBLISH => ['sendNotification', -1000],

    ];

    }


    public function sendNotification(PublishEvent $event)

    {

    $message = new \Swift_Message('Page Published', 'URL: ' . $event->getDocument()->getResourceSegment());


    $this->mailer->send($message);

    }

    }

    View Slide

  79. // src/AppBundle/Document/Subscriber/MailSubscriber.php


    namespace AppBundle\Document\Subscriber;


    use Sulu\Component\DocumentManager\Event\PublishEvent;

    use Sulu\Component\DocumentManager\Events;

    use Symfony\Component\EventDispatcher\EventSubscriberInterface;


    class MailSubscriber implements EventSubscriberInterface

    {

    ...


    /**

    * {@inheritdoc}

    */

    public static function getSubscribedEvents()

    {

    return [

    Events::PUBLISH => ['sendNotification', -1000],

    ];

    }


    public function sendNotification(PublishEvent $event)

    {

    $message = new \Swift_Message('Page Published', 'URL: ' . $event->getDocument()->getResourceSegment());


    $this->mailer->send($message);

    }

    }




    View Slide

  80. Service Container
    The control center for all you application.

    View Slide

  81. Service Container

    View Slide

  82. Service Container
    – Foundation for extensibility &
    customizability

    View Slide

  83. Service Container
    – Foundation for extensibility &
    customizability
    – Sulu heavily uses service definitions

    View Slide

  84. Service Container
    – Foundation for extensibility &
    customizability
    – Sulu heavily uses service definitions
    – Add new functionality
    (Modulnavigation, Content-Type, ...)

    View Slide

  85. Service Container
    – Foundation for extensibility &
    customizability
    – Sulu heavily uses service definitions
    – Add new functionality
    (Modulnavigation, Content-Type, ...)
    – Extend existing (Smart-Content,
    Teaser, ...)

    View Slide

  86. Service Container
    – Foundation for extensibility &
    customizability
    – Sulu heavily uses service definitions
    – Add new functionality
    (Modulnavigation, Content-Type, ...)
    – Extend existing (Smart-Content,
    Teaser, ...)
    – Overwrite services

    View Slide

  87. // src/AppBundle/Admin/AppAdmin.php

    namespace AppBundle\Admin;


    use Sulu\Bundle\AdminBundle\Admin\Admin;

    use Sulu\Bundle\AdminBundle\Navigation\Navigation;

    use Sulu\Bundle\AdminBundle\Navigation\NavigationItem;


    class AppAdmin extends Admin

    {

    public function __construct($title)

    {

    $rootNavigationItem = new NavigationItem($title);

    $section = new NavigationItem('navigation.modules');


    $myModule = new NavigationItem('app.my_module');

    $myModule->setIcon('custom');


    $item = new NavigationItem('app.my_module.title');

    $item->setAction('my_module/custom');

    $myModule->addChild($item);


    $rootNavigationItem->addChild($section);

    $section->addChild($myModule);


    $this->setNavigation(new Navigation($rootNavigationItem));

    }

    }

    View Slide

  88. // src/AppBundle/Admin/AppAdmin.php

    namespace AppBundle\Admin;


    use Sulu\Bundle\AdminBundle\Admin\Admin;

    use Sulu\Bundle\AdminBundle\Navigation\Navigation;

    use Sulu\Bundle\AdminBundle\Navigation\NavigationItem;


    class AppAdmin extends Admin

    {

    public function __construct($title)

    {

    $rootNavigationItem = new NavigationItem($title);

    $section = new NavigationItem('navigation.modules');


    $myModule = new NavigationItem('app.my_module');

    $myModule->setIcon('custom');


    $item = new NavigationItem('app.my_module.title');

    $item->setAction('my_module/custom');

    $myModule->addChild($item);


    $rootNavigationItem->addChild($section);

    $section->addChild($myModule);


    $this->setNavigation(new Navigation($rootNavigationItem));

    }

    }

    %sulu_admin.name%



    View Slide

  89. // src/AppBundle/DependencyInjection/AppCompilerPass.php


    namespace AppBundle\DependencyInjection;


    use AppBundle\Contact\CustomContactManager;

    use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;

    use Symfony\Component\DependencyInjection\ContainerBuilder;

    use Symfony\Component\DependencyInjection\Reference;


    class AppCompilerPass implements CompilerPassInterface

    {

    public function process(ContainerBuilder $container)

    {

    $definition = $container->getDefinition('sulu_contact.contact_manager');

    $definition->setClass(CustomContactManager::class);

    $definition->addArgument(new Reference('app.my_custom_service'));

    }

    }

    View Slide

  90. // src/AppBundle/DependencyInjection/AppCompilerPass.php


    namespace AppBundle\DependencyInjection;


    use AppBundle\Contact\CustomContactManager;

    use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;

    use Symfony\Component\DependencyInjection\ContainerBuilder;

    use Symfony\Component\DependencyInjection\Reference;


    class AppCompilerPass implements CompilerPassInterface

    {

    public function process(ContainerBuilder $container)

    {

    $definition = $container->getDefinition('sulu_contact.contact_manager');

    $definition->setClass(CustomContactManager::class);

    $definition->addArgument(new Reference('app.my_custom_service'));

    }

    }
    // src/AppBundle/AppBundle.php
    namespace AppBundle;
    use AppBundle\DependencyInjection\AppCompilerPass;
    use Symfony\Component\DependencyInjection\ContainerBuilder;
    use Symfony\Component\HttpKernel\Bundle\Bundle;
    class AppBundle extends Bundle
    {
    public function build(ContainerBuilder $container)
    {
    $container->addCompilerPass(new AppCompilerPass());
    }
    }

    View Slide

  91. Thanks for watching!
    www.sulu.io

    View Slide