We don’t need no stinkin’ CMS

This post is more playful than usual but it explores the question of how to achieve most of what one wants a CMS for with a few lines of Javascript and a few lines of server-side code. I’ve coined the term “Source Edit and Content Selection” for this functionality, but this was done primarily to permit me to make a pun that only viewers of a relatively unknown teen movie will understand. Let’s move on and please try to remember the safe word!

What is Wunder-SECS

Wunder-SECS is a framework that does two things:

  • Lets the user edit the placeholders marked in a page, from within the page itself, by specifying assets to fill them or by directly setting content in them,
    and also by setting style properties on elements of the page.
  • Lets the user save the finished page, which, from then on, is independent from Wunder-SECS and can be served by the server and rendered by any browser.

Wunder-SECS uses common Web 2.0 libraries and requires very little effort to work with sites built with standard HTML and CSS, either static or dynamic.

How Wunder-SECS composes pages

Marking an element to be editable (to be a placeholder) is a simple matter of adding a number of CSS classes to it. The mandatory class to add is editable. Other classes distinguish
different kinds of editing required, notably asset selection, of different kinds, or content editing. Wunder-SECS will recover all editable content in the page and apply the necessary
code to it.

Marking for Drag & Resize

Marking for Drag & resize is done with the draggable and resizeable CSS classes, which can also be used in combination.

Marking for Asset Selection

Marking for Asset Selection is done with a number of marker classes, which correspond to the asset folders where the assets reside.
The example below marks the element as a placeholder for assets of kind Flash, which also corresponds to the name of the folder. The placeholder is also draggable
and resizeable.

<h1 id="FlashID-container" class="editable _Flash draggable resizeable"/>

Wunder-SECS makes use of a JSON file called makeup.txt. Inside this file, there are directives that load assets or set content on placeholders, and
other directives that set style properties on elements of the page. This file is shared by all pages in a mini-site which are supposed to share look-and-feel.

Placeholder
An HTML element of the page that will be filled by Wunder-SECS, at page-load time. Any element can be a placeholder but, in order for the editing
facility to be used, the element needs to be marked (cf. Marking for Editing).
Asset
An HTML fragment that will be used to fill a placeholder. Can optionally contain its own inline <style> so that it be self-contained. Such interchangeable fragments
are typically created by Web designers, and need to be placed in asset folders with appropriate names.
Content
An HTML fragment (a.k.a. rich text) that was provided on-the-fly by the page editor. It is not reusable, since it cannot be used in other sites, and contains presentational
markup only.
Style properties
Any CSS property on any element that is useful to change in order to effect a change on a page. An example could be properties that need to be set to various elements in order to change
the page background in a meaningful way.

Editing the page

While entering edit mode, a splash screen is displayed briefly.

w-splash

When working in an editing session, you’ll see the Wunder-SECS toolbar in the page. The toolbar shows a select menu for the theme of the page.

w-toolbar

When you double-click on an asset placeholder, the toolbar will also show an asset select menu of the appropriate kind.

w-toolbar2

Selecting an asset from the select menu has an immediate effect.

w-asset-edit1
w-asset-edit2
w-asset-edit3

Double-clicking on a content placeholder, on the other hand, opens up the jWysiwyg editor.

w-wysiwyg

Placeholders are outlined by colored borders, sometimes two of them, to mark the placeholder and its being draggable at the same time. A resizeable placeholder has the familiar bottom-right handle, to
drag with the mouse, and also draggable bottom and right borders.

w-placeholder

The toolbar is also draggable, so you can move it around if it gets in the way.

Marking for Editing

Marking an element to be editable (to be a placeholder) is a simple matter of adding a number of CSS classes to it. The mandatory class to add is editable. Other classes distinguish
different kinds of editing required, notably asset selection, of different kinds, or content editing. Wunder-SECS will recover all editable content in the page and apply the necessary
code to it.

Marking for Drag & Resize

Marking for Drag & resize is done with the draggable and resizeable CSS classes, which can also be used in combination.

Marking for Asset Selection

Marking for Asset Selection is done with a number of marker classes, which correspond to the asset folders where the assets reside.
The example below marks the element as a placeholder for assets of kind Flash, which also corresponds to the name of the folder. The placeholder is also draggable
and resizeable.

<h1 id="FlashID-container" class="editable _Flash draggable resizeable"/>

Marking for Content Editing

Marking for Content Editing is done with the _Wysiwyg CSS class.
The example below marks the element as a placeholder for formatted text content.

<h1 id="text-container" class="editable _Wysiwyg"/>

Saving the page

When you are ready to save the finished page, you use one of the two “save” buttons.

w-toolbar3

Button “Save” saves the HTML and CSS that you have specified, while button “Save ESI” does the same thing but inside ESI markup. When not running inside a CDN that understands ESI
markup, the page is rendered essentially like before. When ESI markup is available and is interpreted by a CDN, then one can control the cache lifetime, expiration and upgrade of the individual assets inside a page through the facilities offered by the CDN.

<esi:include src="... asset URL ..." />
<esi:remove>... asset contents at time of save ...</esi:remove>

Integrating WunderSECS

To integrate WunderSECS in a page, you need to import the wundersecs.js file. It is written using the module pattern and exposes the WunderSECS global object. If you’re using Require.js or any other dependency manager, you might want to modify it to return the object instead of setting it at global scope.

It has a number of dependencies, which are listed below. If you rely on Require.js or any other dependency manager, remember to codify interdependencies within that list
correctly.

<script src="/appropriate_path/jquery-1.8.3.js" type="text/javascript"></script>
<script src="/appropriate_path/jquery.ui.core.js" type="text/javascript"></script>
<script src="/appropriate_path/jquery.ui.position.js" type="text/javascript"></script>
<script src="/appropriate_path/jquery.ui.widget.js" type="text/javascript"></script>
<script src="/appropriate_path/jquery.ui.mouse.js" type="text/javascript"></script>
<script src="/appropriate_path/jquery.ui.draggable.js" type="text/javascript"></script>
<script src="/appropriate_path/jquery.ui.resizable.js" type="text/javascript"></script>
<script src="/appropriate_path/jquery.ui.selectmenu.js" type="text/javascript"></script>
<script src="/appropriate_path/jquery.validate-1.9.0.min.js" type="text/javascript"></script>
<script src="/appropriate_path/jquery-center.js" type="text/javascript"></script>
<script src="/appropriate_path/wundersecs/wundersecs.js" type="text/javascript"></script>
<script src="/appropriate_path/wysiwyg/jquery.wysiwyg.js" type="text/javascript"></script>
<script src="/appropriate_path/jquery.jeditable.mini.js" type="text/javascript"></script>
<script src="/appropriate_path/wysiwyg/jquery.jeditable.wysiwyg.js" type="text/javascript"></script>
<link href="/appropriate_path/jquery.ui.selectmenu.css" type="text/css" rel="stylesheet" />
<link href="/appropriate_path/jquery-ui-1.8.18-smoothness.css" type="text/css" rel="stylesheet" />
<link href="/appropriate_path/jquery.ui.theme.css" type="text/css" rel="stylesheet" />
<link href="/appropriate_path/wysiwyg/jquery.wysiwyg.css" type="text/css" rel="stylesheet" />

At normal rendering time, you don’t need to do anything. The page is self-contained.

If this is an editing session (which could be conveyed to the page using a parameter, or a cookie, or any other way), then you must call the initEdit function, in order
for edit functionality to be setup, and the WunderSECS toolbar to appear. You must pass the value of the “save” HTTP parameter, which is used by the “save” buttons.

WunderSECS.initEdit(saveFlag);

This is all that needs to be done at the client. At the server, right now the code expects a particular implementation of the AJAX call (WunderSecsEdit.ashx) for editing the makeup, but the specific URL used can be exposed as an initialization option, so as to be able to use it in any application.

Future enhancements might be:

  • Generalizing the background functionality to cover any combination of CSS.
  • Enabling recursive assets, where an asset might have placeholders of its own.

The code for wundersecs.js follows.

var WunderSECS = (function ($) {
    var my = {};
    var makeupData = {};

    function loadFragment(selector, url) {
        if ($(selector).hasClass('_Wysiwyg')) {
            $(selector).html(url);
        } else {
            $(selector).addClass("loading");
            $.get(url, function (data) {
                if (my.saveFlag == "esi") {
                    var esi = $('<esi:include src="' + url + '"/>');
                    $(selector).html(esi);
                    var rem = $('<esi:remove>');
                    $(rem).html(data);
                    $(selector).append(rem);
                } else {
                    $(selector).html(data);
                }
                $(selector).removeClass("loading");
            }, 'html');
        }
    }

    function startsWith(s, str) {
        return s.slice(0, str.length) == str;
    }

    function makeEditable(e) {
        var kind = '';
        if ($(e).hasClass('_Wysiwyg')) {
            var isResizeable = $(e).hasClass("ui-resizable");
            if (isResizeable) {
                $(e).resizable("destroy");
            }
            var wasResizeable;
            $(e).editable(function (val) {
                //                                var html = $.parseHTML(val);
                //                                $(html).remove('.ui-resizable-handle');
                //                                val = $('<div/>').append($(html)).html();

                $.post("/WunderSecsEdit.ashx", {
                    path: window.location.pathname,
                    key: '#' + $(e).attr('id'),
                    value: JSON.stringify(val)
                });
                if (isResizeable) {
                    makeResizeable($(e));
                }
                return val;
            }, {
                type: "wysiwyg",
                event: "dblclick",
                submit: "OK",
                cancel: "Cancel",
                onblur: "ignore"
                , onedit: function () {
                    wasResizeable = $(e).hasClass("ui-resizable");
                    if (wasResizeable) {
                        $(e).resizable("destroy");
                    }
                    return true;
                },
                onreset: function () {
                    if (wasResizeable) {
                        makeResizeable($(e));
                    }
                    return true;
                }
            });
            if (isResizeable) {
                makeResizeable($(e));
            }
            return;
        }
        if ($(e).hasClass('_Moto')) kind = 'Moto';
        if ($(e).hasClass('_TC')) kind = 'TC';
        if ($(e).hasClass('_MsisdnEntry')) kind = 'MsisdnEntry';
        if ($(e).hasClass('_Flash')) kind = 'Flash';
        if ($(e).hasClass('_Sub')) kind = 'Subtitle';
        if ($(e).hasClass('_Terms')) kind = 'SM';
        if ($(e).hasClass('_Button')) kind = 'Button';

        $(e).bind("dblclick", function () {
            $.get('/cms/assets/' + kind + '/files.txt', function (data) { //testing
                var selectlist = $.parseHTML(data);
                $('#editplaceholder').show();
                $('#editlist').html(selectlist);
                $(selectlist).selectmenu({
                    width: 200,
                    select: function (event, options) {
                        $.ajax({
                            type: "GET",
                            url: options.value,
                            success: function (value) {
                                makeupData[$(e).attr('id')] = options.value;
                                $.post("/WunderSecsEdit.ashx", {
                                    path: window.location.pathname,
                                    key: '#' + $(e).attr('id'),
                                    value: JSON.stringify(options.value)
                                });
                                var isDraggable = $(e).hasClass("ui-draggable");
                                if (isDraggable) {
                                    $(e).draggable("destroy");
                                }
                                var isResizeable = $(e).hasClass("ui-resizable");
                                if (isResizeable) {
                                    $(e).resizable("destroy");
                                }
                                $(e).html(value);
                                if (isDraggable) {
                                    makeDraggable($(e));
                                }
                                if (isResizeable) {
                                    makeResizeable($(e));
                                }
                                $('#editplaceholder').hide();
                                $('#editlist').html('');
                            }
                        });
                    }
                });
            }, 'html');
        });
    }

    function makeDraggable(e) {
        $(e).draggable({
            stop: function (event, ui) {
                var top = ui.position.top;
                var left = ui.position.left;
                //alert("Top " + top + ", left " + left);
                $.post("/WunderSecsEdit.ashx", {
                    path: window.location.pathname,
                    key: "position.#" + $(e).attr('id'),
                    value: JSON.stringify({ top: top, left: left })
                });

            }
        });
    }

    function makeResizeable(e) { //todo: look at the CSS and decide which dimensions should be resizeable
        $(e).resizable({
            stop: function (event, ui) {
                var width = ui.size.width;
                var height = ui.size.height;
                $.post("/WunderSecsEdit.ashx", {
                    path: window.location.pathname,
                    key: "size.#" + $(e).attr('id'),
                    value: JSON.stringify({ width: width, height: height })
                });

            }
        });
    }

    function makeEditableBg() {
        $.get('/cms/assets/bg/files.txt', function (data) { //testing
            var selectlist = $.parseHTML(data);
            $('#editbg').html(selectlist);
            $(selectlist).selectmenu({
                width: 200,
                select: function (event, options) {
                    var url = options.value.split('|');
                    makeupData.bg = url;
                    $.post("/WunderSecsEdit.ashx", {
                        path: window.location.pathname,
                        key: "bg",
                        value: JSON.stringify(url)
                    });
                    $('body').css("background", url[0]);
                    $('.oneColFixCtr #container').css("background", url[1]);
                    $('.oneColFixCtr #container .enterSomething.step1').css("background", url[2]);
                }
            });
        }, 'html');
    }

    function showSplash() {
        $('#wundersecs-splash').center().show().css("z-index", 1).css("-moz-box-shadow", "0 0 10px 10px #ffffff").css("-webkit-box-shadow", "0 0 10px 10px #ffffff").css("box-shadow", "0 0 10px 10px #ffffff");
        $('#wundersecs-splash').fadeIn(1000).delay(2000).fadeOut(1000);
    }

    function savePage() {
        var ready = $('.loading').length == 0;
        if (ready) {
            savePageInner();
            return;
        }
        setTimeout(function () {
            savePage();
        }, 1000);
    }

    function savePageInner() {
        var pageHtml = '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">\n<html xmlns="http://www.w3.org/1999/xhtml">\n' + $('html').html() + '\n</html>';
        var fullurl = window.location.href.replace(/save=[^&]*/g, '');
        var url = window.location.pathname;
        $.ajax("/WunderSecsEdit.ashx?path=" + encodeURIComponent(url), {
            type: "PUT",
            mimeType: "text/html",
            data: pageHtml,
            success: function (data, textStatus, jqXHR) {
                window.location.href = fullurl;
            }
        });
    }

    function compose() {
        $.get('makeup.txt', function (data) {
            makeupData = data;
            for (var key in data) {
                var url = data[key];
                //$(key).load(url);
                if (key == "bg") {
                    $('body').css("background", url[0]);
                    $('.oneColFixCtr #container').css("background", url[1]);
                    $('.oneColFixCtr #container .enterSomething.step1').css("background", url[2]);
                } else if (startsWith(key, "position.")) {
                    key = key.slice("position.".length, key.length);
                    $(key).css(url);
                } else if (startsWith(key, "size.")) {
                    key = key.slice("size.".length, key.length);
                    $(key).css(url);
                } else {
                    loadFragment(key, data[key]);
                }
            }
            if (my.saveFlag) savePage();
        }, 'json');
    };

    my.initEdit = function (saveFlag) {
        my.saveFlag = saveFlag;
        compose();

        if (my.saveFlag) return;

        $.get('/stat/wundersecs/wundersecs-toolbar.htm', function (data) {
            var toolbar = $.parseHTML(data);
            $('body').append(toolbar);
            $(toolbar).find('button.savebutton').bind("click", function () {
                window.location.href = window.location.href + '&save=true';
            });
            $(toolbar).find('button.saveesibutton').bind("click", function () {
                window.location.href = window.location.href + '&save=esi';
            });
            $(toolbar).draggable();
        }, 'html');
        $.get('/stat/wundersecs/wundersecs-style.htm', function (data) {
            var style = $.parseHTML(data);
            $('head').append(style);
        }, 'html');
        $.get('/stat/wundersecs/wundersecs-splash.htm', function (data) {
            var splash = $.parseHTML(data);
            $('body').append(splash);
            showSplash();
        }, 'html');

        $('.draggable').each(function (i, e) {
            makeDraggable(e);
        });
        $('.resizeable').each(function (i, e) {
            makeResizeable(e);
        });
        $('.editable').each(function (i, e) {
            makeEditable(e);
        });
        makeEditableBg();

    };

    return my;
})($);

And this might be an implementation for WunderSecsEdit.ashx, purely for demonstration. Feel free to substitute your own.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Web;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

namespace Controller
{
    /// <summary>
    /// Summary description for WunderSecsEdit
    /// </summary>
    public class WunderSecsEdit : IHttpHandler
    {

        public void ProcessRequest(HttpContext context)
        {
            var path = context.Request["path"];
            if (context.Request.HttpMethod == "POST" && path!=null)
            {
                var key = context.Request["key"];
                var value = context.Request["value"];
                if (key != null && value != null)
                {
                    var filepath = path.Substring(0, path.LastIndexOf('/')) + "/makeup.txt";
                    var mappedPath = context.Server.MapPath(filepath);
                    var jsonSerializer = new JsonSerializer();
                    var streamReader = new StreamReader(mappedPath);
                    var makeup = jsonSerializer.Deserialize(new JsonTextReader(streamReader));
                    streamReader.Close();
                    if (makeup is JObject)
                    {
                        var jObject = (JObject) makeup;
                        var jvalue = jsonSerializer.Deserialize(new JsonTextReader(new StringReader(value)));
                        if (jvalue is JToken)
                        {
                            jObject[key] = (JToken) jvalue;
                            var streamWriter = new StreamWriter(mappedPath);
                            jsonSerializer.Serialize(new JsonTextWriter(streamWriter), jObject);
                            streamWriter.Close();
                            //context.Response.ContentType = "text/plain";
                            //context.Response.Write("Hello World");
                        }
                        if (jvalue is string)
                        {
                            jObject[key] = (string) jvalue;
                            var streamWriter = new StreamWriter(mappedPath);
                            jsonSerializer.Serialize(new JsonTextWriter(streamWriter), jObject);
                            streamWriter.Close();
                            //context.Response.ContentType = "text/plain";
                            //context.Response.Write("Hello World");
                        }
                    }
                }
            }
            else if (path != null && context.Request.HttpMethod == "PUT")
            {
                context.Request.InputStream.Position = 0;
                var reader = new StreamReader(context.Request.InputStream);
                string payload = reader.ReadToEnd();
                var mappedPath = context.Server.MapPath(path);
                using (var streamWriter = new StreamWriter(mappedPath))
                {
                    streamWriter.Write(payload);
                    streamWriter.Close();
                }
            }
        }

        public bool IsReusable
        {
            get
            {
                return false;
            }
        }
    }
}
Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s