Книга: Laravel 4 Cookbook
Назад: Embedded Systems
Дальше: Controller Testing

File-Based CMS

October is a Laravel-based, pre-built CMS which was recently announced. I have yet to see the code powering what looks like a beautiful and efficient CMS system. So I thought I would try to implement some of the concepts presented in the introductory video as they illustrate valuable tips for working with Laravel.

The code for this chapter can be found at: https://github.com/formativ/tutorial-laravel-4-file-based-cms

Installing Dependencies

We’re developing a Laravel 4 application which has lots of server-side aspects; but there’s also an interactive interface. There be scripts!

For this; we’re using Bootstrap and jQuery. Download Bootstrap at: http://getbootstrap.com/ and unpack it into your public folder. Where you put the individual files makes little difference, but I have put the scripts in public/js, the stylesheets in public/css and the fonts in public/fonts. Where you see those paths in my source code; you should substitute them with your own.

Next up, download jQuery at: http://jquery.com/download and unpack it into your public folder.

On the server-side, we’re going to be using Flysystem for reading and writing files. Add it to the Composer dependencies:

1 "require" : { 2   "laravel/framework" : "4.1.*", 3   "league/flysystem"  : "0.2.*" 4 }, 

This was extracted from composer.json.

Follow that up with:

1 composer update 

Rendering Templates

We’ve often used View::make() to render views. It’s great for when we have pre-defined view files and we want Laravel to manage how they are rendered and stored. In this tutorial, we’re going to be rendering templates from strings. We’ll need to encapsulate some of how Laravel rendered templates, but it’ll also give us a good base for extending upon the Blade template syntax.

Let’s get started by creating a service provider:

1 php artisan workbench formativ/cms 

This will generate the usual scaffolding for a new package. We need to add it in a few places, to be able to use it in our application:

1 "providers" => [ 2   "Formativ\Cms\CmsServiceProvider", 3   // …remaining service providers 4 ], 

This was extracted from app/config/app.php.

1 "autoload" : { 2   "classmap" : [ 3     // … 4   ], 5   "psr-0" : { 6     "Formativ\\Cms" : "workbench/formativ/cms/src/" 7   } 8 } 

This was extracted from composer.json.

Then we need to rebuild the composer autoloader:

1 composer dump-autoload 

All this gets us to a place where we can start to add classes for encapsulating and extending Blade rendering. Let’s create some wrapper classes, and register them in the service provider:

1 <?php 2   3 namespace Formativ\Cms; 4   5 interface CompilerInterface 6 { 7   public function compileString($template); 8 } 

This file should be saved as workbench/formativ/cms/src/Formativ/Cms/CompilerInterface.php.

 1 <?php  2    3 namespace Formativ\Cms\Compiler;  4    5 use Formativ\Cms\CompilerInterface;  6 use Illuminate\View\Compilers\BladeCompiler;  7    8 class Blade  9 extends BladeCompiler 10 implements CompilerInterface 11 { 12   13 } 

This file should be saved as workbench/formativ/cms/src/Formativ/Cms/Compiler/Blade.php.

1 <?php 2   3 namespace Formativ\Cms; 4   5 interface EngineInterface 6 { 7   public function render($template, $data); 8 } 

This file should be saved as workbench/formativ/cms/src/Formativ/Cms/EngineInterface.php.

 1 <?php  2    3 namespace Formativ\Cms\Engine;  4    5 use Formativ\Cms\CompilerInterface;  6 use Formativ\Cms\EngineInterface;  7    8 class Blade  9 implements EngineInterface 10 { 11   protected $compiler; 12   13   public function __construct(CompilerInterface $compiler) 14   { 15     $this->compiler = $compiler; 16   } 17   18   public function render($template, $data) 19   { 20     $compiled = $this->compiler->compileString($template); 21   22     ob_start(); 23     extract($data, EXTR_SKIP); 24   25     try 26     { 27       eval("?>" . $compiled); 28     } 29     catch (Exception $e) 30     { 31       ob_end_clean(); 32       throw $e; 33     } 34   35     $result = ob_get_contents(); 36     ob_end_clean(); 37   38     return $result; 39   } 40 } 

This file should be saved as workbench/formativ/cms/src/Formativ/Cms/Engine/Blade.php.

 1 <?php  2    3 namespace Formativ\Cms;  4    5 use Illuminate\Support\ServiceProvider;  6    7 class CmsServiceProvider  8 extends ServiceProvider  9 { 10   protected $defer = true; 11   12   public function register() 13   { 14     $this->app->bind( 15       "Formativ\Cms\CompilerInterface", 16       function() { 17         return new Compiler\Blade( 18           $this->app->make("files"), 19           $this->app->make("path.storage") . "/views" 20         ); 21       } 22     ); 23   24     $this->app->bind( 25       "Formativ\Cms\EngineInterface", 26       "Formativ\Cms\Engine\Blade" 27     ); 28   } 29   30   public function provides() 31   { 32     return [ 33       "Formativ\Cms\CompilerInterface", 34       "Formativ\Cms\EngineInterface" 35     ]; 36   } 37 } 

This file should be saved as workbench/formativ/cms/src/Formativ/Cms/CmsServiceProvider.php.

The CompilerBlade class encapsulates the BladeCompiler class, allowing us to implement the CompilerInterface interface. This is a way of future-proofing our package so that the code which depends on methods Blade currently implements won’t fail if future versions of Blade were to remove that implementation.

We’ll also be adding additional template tags in via this class, so it’s not all overhead.

The EngineBlade class contains the method which we will use to render template strings. It implements the EngineInterface interface for that same future-proofing.

We register all of these in the CmsServiceProvider. We can now inject these dependencies in our controller:

 1 <?php  2    3 use Formativ\Cms\EngineInterface;  4    5 class IndexController  6 extends BaseController  7 {  8   protected $engine;  9   10   public function __construct(EngineInterface $engine) 11   { 12     $this->engine = $engine; 13   } 14   15   public function indexAction() 16   { 17     // ...use $this->engine->render() here 18   } 19 } 

This file should be saved as app/controllers/IndexController.php.

As we bound FormativCmsEngineInterface in our service provider, we can now specify it in our controller constructor and Laravel will automatically inject it for us.

Gathering Metadata

One of the interesting things October does is store all of the page and layout meta data at the top of the template file. This allows changes to metadata (which would normally take place elsewhere) to be version-controlled. Having gained the ability to render template strings, we’re now in a position to be able to isolate this kind of metadata and render the rest of the file as a template.

Consider the following example:

 1 protected function minify($html)  2 {  3   $search = array(  4       "/\>[^\S ]+/s",  5       "/[^\S ]+\</s",  6       "/(\s)+/s"  7   );  8    9   $replace = array( 10       ">", 11       "<", 12       "\\1" 13   ); 14   15   $html = preg_replace($search, $replace, $html); 16   17   return $html; 18 } 19   20 public function indexAction() 21 { 22   $template = $this->minify(" 23     <!doctype html> 24     <html lang='en'> 25       <head> 26         <title> 27           Laravel 4 File-Based CMS 28         </title> 29       </head> 30       <body> 31         Hello world 32       </body> 33     </html> 34   "); 35   36   return $this->engine->render($template, []); 37 } 

This was extracted from app/controllers/IndexController.php.

Here we’re rendering a page template (with the help of a minify method). It’s just like what we did before. Let’s add some metadata, and pull it out of the template before rendering:

 1 protected function extractMeta($html)  2 {  3   $parts = explode("==", $html, 2);  4    5   $meta = "";  6   $html = $parts[0];  7    8   if (count($parts) > 1)  9   { 10     $meta = $parts[0]; 11     $html = $parts[1]; 12   } 13   14   return [ 15     "meta" => $meta, 16     "html" => $html 17   ]; 18 } 19   20 protected function parseMeta($meta) 21 { 22   $meta  = trim($meta); 23   $lines = explode("\n", $meta); 24   $data  = []; 25   26   foreach ($lines as $line) 27   { 28     $parts = explode("=", $line); 29     $data[trim($parts[0])] = trim($parts[1]); 30   } 31   32   return $data; 33 } 34   35 public function indexAction() 36 { 37   $parts = $this->extractMeta(" 38     title   = Laravel 4 File-Based CMS 39     message = Hello world 40     == 41     <!doctype html> 42     <html lang='en'> 43       <head> 44         <title> 45           {{ \$title }} 46         </title> 47       </head> 48       <body> 49         {{ \$message }} 50       </body> 51     </html> 52   "); 53   54   $data     = $this->parseMeta($parts["meta"]); 55   $template = $this->minify($parts["html"]); 56   57   return $this->engine->render($template, $data); 58 } 

This was extracted from app/controllers/IndexController.php.

This time round, we’re using an extractMeta() method to pull the meta data string out of the template string, and a parseMeta() method to split the lines of metadata into key/value pairs.

The result is a functional means of storing and parsing meta data, and rendering the remaining template from and to a string.

The minify method is largely unmodified from the original, which I found at: http://stackoverflow.com/questions/6225351/how-to-minify-php-page-html-output.

Creating Layouts

We need to create some sort of admin interface, with which to create and/or modify pages and layouts. Let’s skip the authentication system (as we’ve done that before and it will distract from the focus of this tutorial).

I’ve chosen for us to use Flysystem when working with the filesystem. It would be a good idea to future-proof this dependency by wrapping it in a subclass which implements an interface we control.

 1 <?php  2    3 namespace Formativ\Cms;  4    5 interface FilesystemInterface  6 {  7   public function has($file);  8   public function listContents($folder, $detail = false);  9   public function write($file, $contents); 10   public function read($file); 11   public function put($file, $content); 12   public function delete($file); 13 } 

This file should be saved as workbench/formativ/cms/src/Formativ/Cms/FilesystemInterface.php.

 1 <?php  2    3 namespace Formativ\Cms;  4    5 use League\Flysystem\Filesystem as Base;  6    7 class Filesystem  8 extends Base  9 implements FilesystemInterface 10 { 11     12 } 

This file should be saved as workbench/formativ/cms/src/Formativ/Cms/Filesystem.php.

 1 public function register()  2 {  3   $this->app->bind(  4     "Formativ\Cms\CompilerInterface",  5     function() {  6       return new Compiler\Blade(  7         $this->app->make("files"),  8         $this->app->make("path.storage") . "/views"  9       ); 10     } 11   ); 12   13   $this->app->bind( 14     "Formativ\Cms\EngineInterface", 15     "Formativ\Cms\Engine\Blade" 16   ); 17   18   $this->app->bind( 19     "Formativ\Cms\FilesystemInterface", 20     function() { 21       return new Filesystem( 22         new Local( 23           $this->app->make("path.base") . "/app/views" 24         ) 25       ); 26     } 27   ); 28 } 

This was extracted from workbench/formativ/cms/src/Formativ/Cms/CmsServiceProvider.php.

We’re not really adding any extra functionality to that which Flysystem provides. The sole purpose of us wrapping the Local Flysystem adapter is to make provision for swapping it with another filesystem class/library.

We should also move the metadata-related functionality into a better location.

 1 <?php  2    3 namespace Formativ\Cms;  4    5 interface EngineInterface  6 {  7   public function render($template, $data);  8   public function extractMeta($template);  9   public function parseMeta($meta); 10   public function minify($template); 11 } 

This file should be saved as workbench/formativ/cms/src/Formativ/Cms/EngineInterface.php.

 1 <?php  2    3 namespace Formativ\Cms\Engine;  4    5 use Formativ\Cms\CompilerInterface;  6 use Formativ\Cms\EngineInterface;  7    8 class Blade  9 implements EngineInterface 10 { 11   protected $compiler; 12   13   public function __construct(CompilerInterface $compiler) 14   { 15     $this->compiler = $compiler; 16   } 17   18   public function render($template, $data) 19   { 20     $extracted = $this->extractMeta($template)["template"]; 21     $compiled  = $this->compiler->compileString($extracted); 22   23     ob_start(); 24     extract($data, EXTR_SKIP); 25   26     try 27     { 28       eval("?>" . $compiled); 29     } 30     catch (Exception $e) 31     { 32       ob_end_clean(); 33       throw $e; 34     } 35   36     $result = ob_get_contents(); 37     ob_end_clean(); 38   39     return $result; 40   } 41   42   public function minify($template) 43   { 44     $search = array( 45         "/\>[^\S ]+/s",  46         "/[^\S ]+\</s", 47         "/(\s)+/s" 48     ); 49   50     $replace = array( 51         ">", 52         "<", 53         "\\1" 54     ); 55   56     $template = preg_replace($search, $replace, $template); 57   58     return $template; 59   } 60   61   public function extractMeta($template) 62   { 63     $parts = explode("==", $template, 2); 64   65     $meta     = ""; 66     $template = $parts[0]; 67   68     if (count($parts) > 1) 69     { 70       $meta     = $parts[0]; 71       $template = $parts[1]; 72     } 73   74     return [ 75       "meta"     => $meta, 76       "template" => $template 77     ]; 78   } 79   80   public function parseMeta($meta) 81   { 82     $meta  = trim($meta); 83     $lines = explode("\n", $meta); 84     $data  = []; 85   86     foreach ($lines as $line) 87     { 88       $parts = explode("=", $line); 89       $data[trim($parts[0])] = trim($parts[1]); 90     } 91   92     return $data; 93   } 94 } 

This file should be saved as workbench/formativ/cms/src/Formativ/Cms/Engine/Blade.php.

The only difference can be found in the argument names (to bring them more in line with the rest of the class) and integrating the meta methods into the render() method.

Next up is layout controller class:

 1 <?php  2    3 use Formativ\Cms\EngineInterface;  4 use Formativ\Cms\FilesystemInterface;  5    6 class LayoutController  7 extends BaseController  8 {  9   protected $engine; 10   protected $filesystem; 11   12   public function __construct( 13     EngineInterface $engine, 14     FilesystemInterface $filesystem 15   ) 16   { 17     $this->engine     = $engine; 18     $this->filesystem = $filesystem; 19   20     Validator::extend( 21       "add", 22       function($attribute, $value, $params) { 23         return !$this->filesystem->has("layouts/" . $value); 24       } 25     ); 26   27     Validator::extend( 28       "edit", 29       function($attribute, $value, $params) { 30         $new  = !$this->filesystem->has("layouts/" . $value); 31         $same = $this->filesystem->has("layouts/" . $params[0]); 32   33         return $new or $same; 34       } 35     ); 36   } 37   38   public function indexAction() 39   { 40     $layouts = $this->filesystem->listContents("layouts"); 41     $edit    = URL::route("admin/layout/edit") . "?layout="; 42     $delete  = URL::route("admin/layout/delete") . "?layout="; 43       44     return View::make("admin/layout/index", compact( 45       "layouts", 46       "edit", 47       "delete" 48     )); 49   } 50 } 

This file should be saved as app/controllers/LayoutController.php.

This is the first time we’re using Dependency Injection in our controller. Laravel is injecting our engine interface (which is the Blade wrapper) and our filesystem interface (which is the Flysystem wrapper). As usual, we assign the injected dependencies to protected properties. We also define two custom validation rules, which we’ll use when adding and editing the layout files.

We’ve also defined an indexAction() method which will be used to display a list of layout files which can then be edited or deleted. For the interface to be complete, we are going to need the following files:

 1 <!doctype html>  2 <html lang="en">  3   <head>  4     <meta charset="utf-8" />  5     <title>Laravel 4 File-Based CMS</title>  6     <link  7       rel="stylesheet"  8       href="{{ asset("css/bootstrap.min.css"); }}"  9     /> 10     <link 11       rel="stylesheet" 12       href="{{ asset("css/shared.css"); }}" 13     /> 14   </head> 15   <body> 16     @include("admin/include/navigation") 17     <div class="container"> 18       <div class="row"> 19         <div class="column md-12"> 20           @yield("content") 21         </div> 22       </div> 23     </div> 24     <script src="{{ asset("js/jquery.min.js"); }}"></script> 25     <script src="{{ asset("js/bootstrap.min.js"); }}"></script> 26   </body> 27 </html> 

This file should be saved as app/views/admin/layout.blade.php.

 1 <nav  2   class="navbar navbar-inverse navbar-fixed-top"  3   role="navigation"  4 >  5   <div class="container-fluid">  6     <div class="navbar-header">  7     <button type="button"  8       class="navbar-toggle"  9       data-toggle="collapse" 10       data-target="#navbar-collapse" 11     > 12       <span class="sr-only">Toggle navigation</span> 13       <span class="icon-bar"></span> 14       <span class="icon-bar"></span> 15       <span class="icon-bar"></span> 16     </button> 17   </div> 18   <div 19     class="collapse navbar-collapse" 20     id="navbar-collapse" 21   > 22     <ul class="nav navbar-nav"> 23       <li class="@yield("navigation/layout/class")"> 24         <a href="{{ URL::route("admin/layout/index") }}"> 25           Layouts 26         </a> 27       </li> 28     </div> 29   </div> 30 </nav> 

This file should be saved as app/views/admin/include/navigation.blade.php.

 1 <ol class="breadcrumb">  2   <li>  3     <a href="{{ URL::route("admin/layout/index") }}">  4       List Layouts  5     </a>  6   </li>  7   <li>  8     <a href="{{ URL::route("admin/layout/add") }}">  9       Add New Layout 10     </a> 11   </li> 12 </ol> 

This file should be saved as app/views/admin/include/layout/navigation.blade.php.

 1 @extends("admin/layout")  2 @section("navigation/layout/class")  3   active  4 @stop  5 @section("content")  6   @include("admin/include/layout/navigation")  7   @if (count($layouts))  8     <table class="table table-striped">  9       <thead> 10         <tr> 11           <th class="wide"> 12             File 13           </th> 14           <th class="narrow"> 15             Actions 16           </th> 17         </tr> 18       </thead> 19       <tbody> 20         @foreach ($layouts as $layout) 21           @if ($layout["type"] == "file") 22             <tr> 23               <td class="wide"> 24                 <a href="{{ $edit . $layout["basename"] }}"> 25                   {{ $layout["basename"] }} 26                 </a> 27               </td> 28               <td class="narrow actions"> 29                 <a href="{{ $edit . $layout["basename"] }}"> 30                   <i class="glyphicon glyphicon-pencil"></i> 31                 </a> 32                 <a href="{{ $delete . $layout["basename"] }}"> 33                   <i class="glyphicon glyphicon-trash"></i> 34                 </a> 35               </td> 36             </tr> 37           @endif 38         @endforeach 39       </tbody> 40     </table> 41     @else 42       No layouts yet. 43       <a href="{{ URL::route("admin/layout/add") }}"> 44         create one now! 45       </a> 46     @endif 47 @stop 

This file should be saved as app/views/admin/layout/index.blade.php.

1 Route::any("admin/layout/index", [ 2   "as"   => "admin/layout/index", 3   "uses" => "LayoutController@indexAction" 4 ]); 

This was extracted from app/routes.php.

These are quite a few files, so let’s go over them individually:

  1. The first file is the main admin layout template. Every page in the admin area should be rendered within this layout template. As you can see, we’ve linked the jQuery and Bootstrap assets to provide some styling and interactive functionality to the admin area.
  2. The second file is the main admin navigation template. This will also be present on every page in the admin area, though it’s better to include it in the main layout template than to clutter the main layout template with secondary markup.
  3. The third file is a sub-navigation template, only present in the pages concerning layouts.
  4. The fourth file is the layout index (or listing) page. It includes the sub-navigation and renders a table row for each layout file it finds. If none can be found, it will present a cheeky message for the user to add one.
  5. Finally we add the index route to the routes.php file. At this point, the page should be visible via the browser. As there aren’t any layout files yet, you should see the cheeky message instead.

Let’s move onto the layout add page:

 1 public function addAction()  2 {  3   if (Input::has("save"))  4   {  5     $validator = Validator::make(Input::all(), [  6       "name" => "required|add",  7       "code" => "required"  8     ]);  9   10     if ($validator->fails()) 11     { 12       return Redirect::route("admin/layout/add") 13         ->withInput() 14         ->withErrors($validator); 15     } 16   17     $meta = " 18       title       = " . Input::get("title") . " 19       description = " . Input::get("description") . " 20       == 21     "; 22   23     $name = "layouts/" . Input::get("name") . ".blade.php"; 24   25     $this->filesystem->write($name, $meta . Input::get("code")); 26   27     return Redirect::route("admin/layout/index"); 28   } 29   30   return View::make("admin/layout/add"); 31 } 

This was extracted from app/controllers/LayoutController.php.

 1 @extends("admin/layout")  2 @section("navigation/layout/class")  3   active  4 @stop  5 @section("content")  6   @include("admin/include/layout/navigation")  7   <form role="form" method="post">  8     <div class="form-group">  9       <label for="name">Name</label> 10       <span class="help-text text-danger"> 11         {{ $errors->first("name") }} 12       </span> 13       <input 14         type="text" 15         class="form-control" 16         id="name" 17         name="name" 18         placeholder="new-layout" 19         value="{{ Input::old("name") }}" 20       /> 21     </div> 22     <div class="form-group"> 23       <label for="title">Meta Title</label> 24       <input 25         type="text" 26         class="form-control" 27         id="title" 28         name="title" 29         value="{{ Input::old("title") }}" 30       /> 31     </div> 32     <div class="form-group"> 33       <label for="description">Meta Description</label> 34       <input 35         type="text" 36         class="form-control" 37         id="description" 38         name="description" 39         value="{{ Input::old("description") }}" 40       /> 41     </div> 42     <div class="form-group"> 43       <label for="code">Code</label> 44       <span class="help-text text-danger"> 45         {{ $errors->first("code") }} 46       </span> 47       <textarea 48         class="form-control" 49         id="code" 50         name="code" 51         rows="5" 52         placeholder="&lt;div&gt;Hello world&lt;/div&gt;" 53       >{{ Input::old("code") }}</textarea> 54     </div> 55     <input 56       type="submit" 57       name="save" 58       class="btn btn-default" 59       value="Save" 60     /> 61   </form> 62 @stop 

This file should be saved as app/views/admin/layout/add.blade.php.

1 Route::any("admin/layout/add", [ 2   "as"   => "admin/layout/add", 3   "uses" => "LayoutController@addAction" 4 ]); 

This was extracted from app/routes.php.

The form processing, in the addAction() method, is wrapped in a check for the save parameter. This is the name of the submit button on the add form. We specify the validation rules (including one of those we defined in the constructor). If validation fails, we redirect back to the add page, bringing along the errors and old input. If not, we create a new file with the default meta title and default meta description as metadata. Finally we redirect to the index page.

The view is fairly standard (including the bootstrap tags we’ve used). The name and code fields have error messages and all of the fields have their values set to the old input values. We’ve also added a route to the add page.

Edit follows a similar pattern:

 1 public function editAction()  2 {  3   $layout          = Input::get("layout");  4   $name            = str_ireplace(".blade.php", "", $layout);  5   $content         = $this->filesystem->read("layouts/" . $layout);  6   $extracted       = $this->engine->extractMeta($content);  7   $code            = trim($extracted["template"]);  8   $parsed          = $this->engine->parseMeta($extracted["meta"]);  9   $title           = $parsed["title"]; 10   $description     = $parsed["description"]; 11   12   if (Input::has("save")) 13   { 14     $validator = Validator::make(Input::all(), [ 15       "name" => "required|edit:" . Input::get("layout"), 16       "code" => "required" 17     ]); 18   19     if ($validator->fails()) 20     { 21       return Redirect::route("admin/layout/edit") 22         ->withInput() 23         ->withErrors($validator); 24     } 25   26     $meta = " 27       title       = " . Input::get("title") . " 28       description = " . Input::get("description") . " 29       == 30     "; 31   32     $name = "layouts/" . Input::get("name") . ".blade.php"; 33   34     $this->filesystem->put($name, $meta . Input::get("code")); 35   36     return Redirect::route("admin/layout/index"); 37   } 38   39   return View::make("admin/layout/edit", compact( 40     "name", 41     "title", 42     "description", 43     "code" 44   )); 45 } 

This was extracted from app/controllers/LayoutController.php.

 1 @extends("admin/layout")  2 @section("navigation/layout/class")  3   active  4 @stop  5 @section("content")  6   @include("admin/include/layout/navigation")  7   <form role="form" method="post">  8     <div class="form-group">  9       <label for="name">Name</label> 10       <span class="help-text text-danger"> 11         {{ $errors->first("name") }} 12       </span> 13       <input 14         type="text" 15         class="form-control" 16         id="name" 17         name="name" 18         placeholder="new-layout" 19         value="{{ Input::old("name", $name) }}" 20       /> 21     </div> 22     <div class="form-group"> 23       <label for="title">Meta Title</label> 24       <input 25         type="text" 26         class="form-control" 27         id="title" 28         name="title" 29         value="{{ Input::old("title", $title) }}" 30       /> 31     </div> 32     <div class="form-group"> 33       <label for="description">Meta Description</label> 34       <input 35         type="text" 36         class="form-control" 37         id="description" 38         name="description" 39         value="{{ Input::old("description", $description) }}" 40       /> 41     </div> 42     <div class="form-group"> 43       <label for="code">Code</label> 44       <span class="help-text text-danger"> 45         {{ $errors->first("code") }} 46       </span> 47       <textarea 48         class="form-control" 49         id="code" 50         name="code" 51         rows="5" 52         placeholder="&lt;div&gt;Hello world&lt;/div&gt;" 53       >{{ Input::old("code", $code) }}</textarea> 54     </div> 55     <input 56       type="submit" 57       name="save" 58       class="btn btn-default" 59       value="Save" 60     /> 61   </form> 62 @stop 

This file should be saved as app/views/admin/layout/edit.blade.php.

1 Route::any("admin/layout/edit", [ 2   "as"   => "admin/layout/edit", 3   "uses" => "LayoutController@editAction" 4 ]); 

This was extracted from app/routes.php.

The editAction() method fetches the layout file data and extracts/parses the metadata, so that we can present it in the edit form. Other than utilising the second custom validation function, we define in the constructor, there’s nothing else noteworthy in this method.

The edit form is also pretty much the same, except that we provide default values to the Input::old() method calls, giving the data extracted from the layout file. We also add a route to the edit page.

You may notice that the file name remains editable, on the edit page. When you change this value, and save the layout it won’t change the name of the current layout file, but rather generate a new layout file. This is an interesting (and in this case useful) side-effect of using the file name as the unique identifier for layout files.

Deleting layout files is even simpler:

1 public function deleteAction() 2 { 3   $name = "layouts/" . Input::get("layout"); 4   $this->filesystem->delete($name); 5   6   return Redirect::route("admin/layout/index"); 7 } 

This was extracted from app/controllers/LayoutController.php.

1 Route::any("admin/layout/delete", [ 2   "as"   => "admin/layout/delete", 3   "uses" => "LayoutController@deleteAction" 4 ]); 

This was extracted from app/routes.php.

We link straight to the deleteAction() method in the index view. This method simply deletes the layout file and redirects back to the index page. We’ve added the appropriate route to make this page accessible.

We can now list the layout files, add new ones, edit existing ones and delete those layout files we no longer require. It’s basic, and could definitely be polished a bit, but it’s sufficient for our needs.

Creating Pages

Pages are handled in much the same way, so we’re not going to spend too much time on them. Let’s begin with the controller:

  1 <?php   2     3 use Formativ\Cms\EngineInterface;   4 use Formativ\Cms\FilesystemInterface;   5     6 class PageController   7 extends BaseController   8 {   9   protected $engine;  10   protected $filesystem;  11    12   public function __construct(  13     EngineInterface $engine,  14     FilesystemInterface $filesystem  15   )  16   {  17     $this->engine     = $engine;  18     $this->filesystem = $filesystem;  19    20     Validator::extend(  21       "add",  22       function($attribute, $value, $params) {  23         return !$this->filesystem->has("pages/" . $value);  24       }  25     );  26     27     Validator::extend(  28       "edit",  29       function($attribute, $value, $params) {  30         $new  = !$this->filesystem->has("pages/" . $value);  31         $same = $this->filesystem->has("pages/" . $params[0]);  32    33         return $new or $same;  34       }  35     );  36   }  37    38   public function indexAction()  39   {  40     $pages  = $this->filesystem->listContents("pages");  41     $edit   = URL::route("admin/page/edit") . "?page=";  42     $delete = URL::route("admin/page/delete") . "?page=";  43        44     return View::make("admin/page/index", compact(  45       "pages",  46       "edit",  47       "delete"  48     ));  49   }  50    51   public function addAction()  52   {  53     $files   = $this->filesystem->listContents("layouts");  54     $layouts = [];  55    56     foreach ($files as $file)  57     {  58       $name = $file["basename"];  59       $layouts[$name] = $name;  60     }  61    62     if (Input::has("save"))  63     {  64       $validator = Validator::make(Input::all(), [  65         "name"   => "required|add",  66         "route"  => "required",  67         "layout" => "required",  68         "code"   => "required"  69       ]);  70    71       if ($validator->fails())  72       {  73         return Redirect::route("admin/page/add")  74           ->withInput()  75           ->withErrors($validator);  76       }  77    78       $meta = "  79         title       = " . Input::get("title") . "  80         description = " . Input::get("description") . "  81         layout      = " . Input::get("layout") . "  82         route       = " . Input::get("route") . "  83         ==  84       ";  85    86       $name = "pages/" . Input::get("name") . ".blade.php";  87       $code = $meta . Input::get("code");  88    89       $this->filesystem->write($name, $code);  90    91       return Redirect::route("admin/page/index");  92     }  93    94     return View::make("admin/page/add", compact(  95       "layouts"  96     ));  97   }  98    99   public function editAction() 100   { 101     $files   = $this->filesystem->listContents("layouts"); 102     $layouts = []; 103   104     foreach ($files as $file) 105     { 106       $name = $file["basename"]; 107       $layouts[$name] = $name; 108     } 109   110     $page            = Input::get("page"); 111     $name            = str_ireplace(".blade.php", "", $page); 112     $content         = $this->filesystem->read("pages/" . $page); 113     $extracted       = $this->engine->extractMeta($content); 114     $code            = trim($extracted["template"]); 115     $parsed          = $this->engine->parseMeta($extracted["meta"]); 116     $title           = $parsed["title"]; 117     $description     = $parsed["description"]; 118     $route           = $parsed["route"]; 119     $layout          = $parsed["layout"]; 120   121     if (Input::has("save")) 122     { 123       $validator = Validator::make(Input::all(), [ 124         "name"    => "required|edit:" . Input::get("page"), 125         "route"   => "required", 126         "layout"  => "required", 127         "code"    => "required" 128       ]); 129   130       if ($validator->fails()) 131       { 132         return Redirect::route("admin/page/edit") 133           ->withInput() 134           ->withErrors($validator); 135       } 136   137       $meta = " 138         title       = " . Input::get("title") . " 139         description = " . Input::get("description") . " 140         layout      = " . Input::get("layout") . " 141         route       = " . Input::get("route") . " 142         == 143       "; 144   145       $name = "pages/" . Input::get("name") . ".blade.php"; 146   147       $this->filesystem->put($name, $meta . Input::get("code")); 148   149       return Redirect::route("admin/page/index"); 150     } 151   152     return View::make("admin/page/edit", compact( 153       "name", 154       "title", 155       "description", 156       "layout", 157       "layouts", 158       "route", 159       "code" 160     )); 161   } 162   163   public function deleteAction() 164   { 165     $name = "pages/" . Input::get("page"); 166     $this->filesystem->delete($name); 167   168     return Redirect::route("admin/page/index"); 169   } 170 } 

This file should be saved as app/controllers/PageController.php.

The constructor method accepts the same injected dependencies as our layout controller did. We also define similar custom validation rules to check the names of files we want to save.

The addAction() method differs slightly in that we load the existing layout files so that we can designate the layout for each page. We also add this (and the route parameter) to the metadata saved to the page file.

The editAction() method loads the route and layout parameters (in addition to the other fields) and passes them to the edit page template, where they will be used to populate the new fields.

 1 <nav  2   class="navbar navbar-inverse navbar-fixed-top"  3   role="navigation"  4 >  5   <div class="container-fluid">  6     <div class="navbar-header">  7       <button type="button"  8         class="navbar-toggle"  9         data-toggle="collapse" 10         data-target="#navbar-collapse" 11       > 12         <span class="sr-only">Toggle navigation</span> 13         <span class="icon-bar"></span> 14         <span class="icon-bar"></span> 15         <span class="icon-bar"></span> 16       </button> 17     </div> 18     <div class="collapse navbar-collapse" id="navbar-collapse"> 19       <ul class="nav navbar-nav"> 20         <li class="@yield("navigation/layout/class")"> 21           <a href="{{ URL::route("admin/layout/index") }}"> 22             Layouts 23           </a> 24         </li> 25         <li class="@yield("navigation/page/class")"> 26           <a href="{{ URL::route("admin/page/index") }}"> 27             Pages 28           </a> 29         </li> 30       </ul> 31     </div> 32   </div> 33 </nav> 

This file should be saved as app/views/admin/include/navigation.blade.php.

 1 <ol class="breadcrumb">  2   <li>  3     <a href="{{ URL::route("admin/page/index") }}">  4       List Pages  5     </a>  6   </li>  7   <li>  8     <a href="{{ URL::route("admin/page/add") }}">  9       Add New Page 10     </a> 11   </li> 12 </ol> 

This file should be saved as app/views/admin/include/page/navigation.blade.php.

 1 @extends("admin/layout")  2 @section("navigation/page/class")  3   active  4 @stop  5 @section("content")  6   @include("admin/include/page/navigation")  7   @if (count($pages))  8     <table class="table table-striped">  9       <thead> 10         <tr> 11           <th class="wide"> 12             File 13           </th> 14           <th class="narrow"> 15             Actions 16           </th> 17         </tr> 18       </thead> 19       <tbody> 20         @foreach ($pages as $page) 21           @if ($page["type"] == "file") 22             <tr> 23               <td class="wide"> 24                 <a href="{{ $edit . $page["basename"] }}"> 25                   {{ $page["basename"] }} 26                 </a> 27               </td> 28               <td class="narrow actions"> 29                 <a href="{{ $edit . $page["basename"] }}"> 30                   <i class="glyphicon glyphicon-pencil"></i> 31                 </a> 32                 <a href="{{ $delete . $page["basename"] }}"> 33                   <i class="glyphicon glyphicon-trash"></i> 34                 </a> 35               </td> 36             </tr> 37           @endif 38         @endforeach 39       </tbody> 40     </table> 41     @else 42       No pages yet. 43       <a href="{{ URL::route("admin/page/add") }}"> 44         create one now! 45       </a> 46     @endif 47 @stop 

This file should be saved as app/views/admin/page/index.blade.php.

 1 @extends("admin/layout")  2 @section("navigation/page/class")  3   active  4 @stop  5 @section("content")  6   @include("admin/include/page/navigation")  7   <form role="form" method="post">  8     <div class="form-group">  9       <label for="name">Name</label> 10       <span class="help-text text-danger"> 11         {{ $errors->first("name") }} 12       </span> 13       <input 14         type="text" 15         class="form-control" 16         id="name" 17         name="name" 18         placeholder="new-page" 19         value="{{ Input::old("name") }}" 20       /> 21     </div> 22     <div class="form-group"> 23       <label for="route">Route</label> 24       <span class="help-text text-danger"> 25         {{ $errors->first("route") }} 26       </span> 27       <input 28         type="text" 29         class="form-control" 30         id="route" 31         name="route" 32         placeholder="/new-page" 33         value="{{ Input::old("route") }}" 34       /> 35     </div> 36     <div class="form-group"> 37       <label for="layout">Layout</label> 38       <span class="help-text text-danger"> 39         {{ $errors->first("layout") }} 40       </span> 41       {{ Form::select( 42         "layout", 43         $layouts, 44         Input::old("layout"),   45         [ 46           "id"    => "layout", 47           "class" => "form-control" 48         ] 49       ) }} 50     </div> 51     <div class="form-group"> 52       <label for="title">Meta Title</label> 53       <input 54         type="text" 55         class="form-control" 56         id="title" 57         name="title" 58         value="{{ Input::old("title") }}" 59       /> 60     </div> 61     <div class="form-group"> 62       <label for="description">Meta Description</label> 63       <input 64         type="text" 65         class="form-control" 66         id="description" 67         name="description" 68         value="{{ Input::old("description") }}" 69       /> 70     </div> 71     <div class="form-group"> 72       <label for="code">Code</label> 73       <span class="help-text text-danger"> 74         {{ $errors->first("code") }} 75       </span> 76       <textarea 77         class="form-control" 78         id="code" 79         name="code" 80         rows="5" 81         placeholder="&lt;div&gt;Hello world&lt;/div&gt;" 82       >{{ Input::old("code") }}</textarea> 83     </div> 84     <input 85       type="submit" 86       name="save" 87       class="btn btn-default" 88       value="Save" 89     /> 90   </form> 91 @stop 

This file should be saved as app/views/admin/page/add.blade.php.

 1 @extends("admin/layout")  2 @section("navigation/page/class")  3   active  4 @stop  5 @section("content")  6   @include("admin/include/page/navigation")  7   <form role="form" method="post">  8     <div class="form-group">  9       <label for="name">Name</label> 10       <span class="help-text text-danger"> 11         {{ $errors->first("name") }} 12       </span> 13       <input 14         type="text" 15         class="form-control" 16         id="name" 17         name="name" 18         placeholder="new-page" 19         value="{{ Input::old("name", $name) }}" 20       /> 21     </div> 22     <div class="form-group"> 23       <label for="route">Route</label> 24       <span class="help-text text-danger"> 25         {{ $errors->first("route") }} 26       </span> 27       <input 28         type="text" 29         class="form-control" 30         id="route" 31         name="route" 32         placeholder="/new-page" 33         value="{{ Input::old("route", $route) }}" 34       /> 35     </div> 36     <div class="form-group"> 37       <label for="layout">Layout</label> 38       <span class="help-text text-danger"> 39         {{ $errors->first("layout") }} 40       </span> 41       {{ Form::select("layout", $layouts, Input::old("layout", $layout), [ 42         "id"    => "layout", 43         "class" => "form-control" 44       ]) }} 45     </div> 46     <div class="form-group"> 47       <label for="title">Meta Title</label> 48       <input 49         type="text" 50         class="form-control" 51         id="title" 52         name="title" 53         value="{{ Input::old("title", $title) }}" 54       /> 55     </div> 56     <div class="form-group"> 57       <label for="description">Meta Description</label> 58       <input 59         type="text" 60         class="form-control" 61         id="description" 62         name="description" 63         value="{{ Input::old("description", $description) }}" 64       /> 65     </div> 66     <div class="form-group"> 67       <label for="code">Code</label> 68       <span class="help-text text-danger"> 69         {{ $errors->first("code") }} 70       </span> 71       <textarea 72         class="form-control" 73         id="code" 74         name="code" 75         rows="5" 76         placeholder="&lt;div&gt;Hello world&lt;/div&gt;" 77       >{{ Input::old("code", $code) }}</textarea> 78     </div> 79     <input type="submit" name="save" class="btn btn-default" value="Save" /> 80   </form> 81 @stop 

This file should be saved as app/views/admin/page/edit.blade.php.

The views follow a similar pattern to those which we created for managing layout files. The exception is that we add the new layout and route fields to the add and edit page templates. We’ve used the Form::select() method to render and select the appropriate layout.

 1 Route::any("admin/page/index", [  2   "as"   => "admin/page/index",  3   "uses" => "PageController@indexAction"  4 ]);  5    6 Route::any("admin/page/add", [  7   "as"   => "admin/page/add",  8   "uses" => "PageController@addAction"  9 ]); 10   11 Route::any("admin/page/edit", [ 12   "as"   => "admin/page/edit", 13   "uses" => "PageController@editAction" 14 ]); 15   16 Route::any("admin/page/delete", [ 17   "as"   => "admin/page/delete", 18   "uses" => "PageController@deleteAction" 19 ]); 

This was extracted from app/routes.php.

Finally, we add the routes which will allow us to access these pages. With all this in place, we can work on displaying the website content.

Displaying Content

Aside from our admin pages, we need to be able to catch the all requests, and route them to a single controller/action. We do this by appending the following route to the routes.php file:

1 Route::any("{all}", [ 2   "as"   => "index/index", 3   "uses" => "IndexController@indexAction" 4 ])->where("all", ".*"); 

This was extracted from app/routes.php.

We pass all route information to a named parameter ({all}), which will be mapped to the IndexController::indexAction() method. We also need to specify the regular expression with which the route data should be matched. With ”.*” we’re telling Laravel to match absolutely anything. This is why this route needs to come right at the end of the app/routes.php file.

  1 <?php   2     3 use Formativ\Cms\EngineInterface;   4 use Formativ\Cms\FilesystemInterface;   5     6 class IndexController   7 extends BaseController   8 {   9   protected $engine;  10   protected $filesystem;  11    12   public function __construct(  13     EngineInterface $engine,  14     FilesystemInterface $filesystem  15   )  16   {  17     $this->engine     = $engine;  18     $this->filesystem = $filesystem;  19   }  20    21   protected function parseFile($file)  22   {  23     return $this->parseContent(  24       $this->filesystem->read($file["path"]),  25       $file  26     );  27   }  28    29   protected function parseContent($content, $file = null)  30   {  31     $extracted = $this->engine->extractMeta($content);  32     $parsed    = $this->engine->parseMeta($extracted["meta"]);  33    34     return compact("file", "content", "extracted", "parsed");  35   }  36    37   protected function stripExtension($name)  38   {  39     return str_ireplace(".blade.php", "", $name);  40   }  41    42   protected function cleanArray($array)  43   {  44     return array_filter($array, function($item) {  45       return !empty($item);  46     });  47   }  48    49   public function indexAction($route = "/")  50   {  51     $pages = $this->filesystem->listContents("pages");  52    53     foreach ($pages as $page)  54     {  55       if ($page["type"] == "file")  56       {  57         $page = $this->parseFile($page);  58    59         if ($page["parsed"]["route"] == $route)  60         {  61           $basename   = $page["file"]["basename"];  62           $name       = "pages/extracted/" . $basename;  63           $layout     = $page["parsed"]["layout"];  64           $layoutName = "layouts/extracted/" . $layout;  65           $extends    = $this->stripExtension($layoutName);  66    67           $template = "  68             @extends('" . $extends . "')  69             @section('page')  70               " . $page["extracted"]["template"] . "  71             @stop  72           ";  73    74           $this->filesystem->put($name, trim($template));  75    76           $layout = "layouts/" . $layout;  77    78           $layout = $this->parseContent(  79             $this->filesystem->read($layout)  80           );  81    82           $this->filesystem->put(  83             $layoutName,  84             $layout["extracted"]["template"]  85           );  86    87           $data = array_merge(  88             $this->cleanArray($layout["parsed"]),  89             $this->cleanArray($page["parsed"])  90           );  91    92           return View::make(  93             $this->stripExtension($name),  94             $data  95            );  96         }  97       }  98     }  99   } 100 } 

This file should be saved as app/controllers/IndexController.php.

In the IndexController class, we’ve injected the same two dependencies: $filesystem and $engine. We need these to fetch and extract the template data.

We begin by fetching all the files in the app/views/pages directory. We iterate through them filtering out all the returned items which have a type of file. From these, we fetch the metadata and check if the route defined matches that which is being requested.

If there is a match, we extract the template data and save it to a new file, resembling app/views/pages/extracted/[original name]. We then fetch the layout defined in the metadata, performing a similar transformation. We do this because we still want to run the templates through Blade (so that @extends, @include, @section etc.) all still work as expected.

We filter the page metadata and the layout metadata, to omit any items which do not have values, and we pass the merged array to the view. Blade takes over and we have a rendered view!

Extending The CMS

We’ve implemented the simplest subset of October functionality. There’s a lot more going on that I would love to implement, but we’ve run out of time to do so. If you’ve found this project interesting, perhaps you would like to take a swing at implementing partials (they’re not much work if you prevent them from having metadata). Or perhaps you’re into JavaScript and want to try your hand at emulating some of the Ajax framework magic that October’s got going on…

You can learn more about OctoberCMS’s features at: http://octobercms.com/docs/cms/themes.

Назад: Embedded Systems
Дальше: Controller Testing