Ceramic ยป Examples

The following examples illustrate the structure of a Ceramic application.

MarkEdit

MarkEdit is a simple Markdown editor and previewer, designed to showcase the basic set of Ceramic's functionality. This is a breakdown of its implementation.

System Definition

This is where we start: we define the project's metadata, depend on Lucerne (for the web app) and 3bmd for Markdown-to-HTML conversion. We also depend on a small extension for GitHub-style code blocks.

(defsystem markedit
  :author "Fernando Borretti <eudoxiahp@gmail.com>"
  :maintainer "Fernando Borretti <eudoxiahp@gmail.com>"
  :license "MIT"
  :version "0.1"
  :homepage "https://github.com/ceramic/markedit"
  :bug-tracker "https://github.com/ceramic/markedit/issues"
  :source-control (:git "git@github.com:ceramic/markedit.git")
  :depends-on (:ceramic
               :lucerne
               :3bmd
               :3bmd-ext-code-blocks)
  :components ((:module "assets"
                :components
                ((:module "css"
                  :components
                  ((:static-file "style.css")))
                 (:module "js"
                  :components
                  ((:static-file "scripts.js")))))
               (:module "src"
                :serial t
                :components
                ((:file "markedit"))))
  :description "A Markdown editor example with Ceramic."
  :long-description
  #.(uiop:read-file-string
     (uiop:subpathname *load-pathname* "README.md"))
  :in-order-to ((test-op (test-op markedit-test))))

Server

First, the package definition. We use all of :lucerne to make the views easier, import define-resources and resource-directory from :ceramic.resource and export both the app object and the start-app function.

Note how at the end we insert a call to annot:enable-annot-syntax. This allows us to use the @route reader macro.

(in-package :cl-user)
(defpackage markedit
  (:use :cl :lucerne)
  (:import-from :ceramic.resource
                :define-resources
                :resource-directory)
  (:export :app
           :start-app)
  (:documentation "Main MarkEdit code."))
(in-package :markedit)
(annot:enable-annot-syntax)

Now we define the app's resources, by associating the assets tag with the assets/ directory and, similarly, associating templates with templates/.

;;; App resources

(define-resources :markedit ()
  (assets #p"assets/")
  (templates #p"templates/"))

Then, we define the app, and tell it to use the static-file middleware to serve files in the assets directory on the /static/ path.

;;; App

(defapp app
  :middlewares ((clack.middleware.static:<clack-middleware-static>
                 :root (resource-directory 'assets)
                 :path "/static/")))

Next, templates: We tell Djula to look for them in the directory in the templates tag, and tell it to compile the "index.html" template it finds there.

;;; Templates

(djula:add-template-directory
 (resource-directory 'templates))

(defparameter +index+
  (djula:compile-template* "index.html"))

And now, the actual views: We define a convenience markdown-to-html function that does precisely what it says on the tin. Then, two views: The index view that shows the editor/previewer template and the API view that takes a Markdown string and responds with the corresponding HTML.

;;; Views

(defun markdown-to-html (string)
  (with-output-to-string (stream)
    (let ((3bmd-code-blocks:*code-blocks* t)
          (3bmd:*smart-quotes* t))
      (3bmd:parse-string-and-print-to-stream string stream))))

@route app "/"
(defview index ()
  "The index page displays the editor."
  (render-template (+index+)))

@route app (:post "/to-html")
(defview to-html ()
  "This part of the API receives Markdown and emits HTML"
  (with-params (markdown)
    (respond (markdown-to-html markdown))))

Finally, some startup code:

(defparameter *port* 9000)

(ceramic:define-entry-point :markedit ()
  (start app :port *port*)
  (let ((window (ceramic:make-window :url (format nil "http://localhost:~D/" *port*))))
    (ceramic:show window)))

Templates

As usual, we begin with a base.html template that other templates inherit from:

<!DOCTYPE html>
<html lang="en">
  {% include "includes/head.html" %}
  <body>
    {% block content %}{% endblock %}
  </body>
</html>

In includes/head.html, we just load our assets:

<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>MarkEdit</title>
  <link href="/static/css/style.css" rel="stylesheet">
  <script src="/static/js/scripts.js"></script>
</head>

And finally, the index.html template, which holds the editor and previewer panes:

{% extends "base.html" %}

{% block content %}
  <div id="input-container">
    <textarea id="input"></textarea>
  </div>
  <div id="output-container">
    <div id="output">
    </div>
  </div>
{% endblock %}

JavaScript

We'll just write some awful JavaScript that checks periodically for changes to the input textarea, and if it finds them, sends them to the server and inserts the result into the output pane.

/* JavaScript */

function textareaHasInput(node) {
  return (node.value != null && node.value != "");
};

var last_text = null;

function changed(text) {
  return !(last_text != null && text == last_text);
}

function postMarkdown() {
  // When the Markdown changes, POST it to the server and get the resulting HTML
  var input = document.getElementById("input");
  if(textareaHasInput(input) && changed(input.value)) {
    console.log("Sending input");
    last_text = input.value;
    var req = new XMLHttpRequest();
    req.onreadystatechange = function() {
      var output = document.getElementById("output");
      const new_html = req.responseText;
      if(output.innerHTML != new_html) {
        output.innerHTML = new_html;
      }
    };
    req.open("POST", "/to-html", true);
    req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
    req.send("markdown=" + encodeURIComponent(input.value));
  }
};

document.addEventListener("DOMContentLoaded", function(event) {
  setInterval(postMarkdown, 500);
  postMarkdown();
});

Style

First, the general layout:

@charset "utf-8";

/* Make the textarea and output occupy two columns on the entire screen. */

body {
    margin: 0 auto;
}

#input-container, #output-container {
    width: 50%;
    height: 100vh;
    margin: 0;
    border: 0;
    float: left;
}

/* Give both input and output a gap separating them from their containers */

#input, #output {
    display: block; /* <textarea> needs this */
    width: 90%;
    margin: 0 auto;
    border: 0;
    padding: 0;
}

#input {
    height: 100%;
    font-family: Consolas, Menlo, Monaco, Lucida Console, monospace;
    resize: none;
}

We then style the input textarea: Here's where we create the black border separating the two panes, and add some padding.

/* Input textarea style */

#input-container {
    border-right: 1px solid black;
    box-sizing: border-box;
    padding: 25px;
}

The next section is just styling the output so it looks good:

/* Output style */

a:link, a:visited, a:hover, a:active {
    color: #4183C4;
    text-decoration: none;
}

#output-container {
    font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
    font-size: 16px;
}

code {
    font-size: 90%;
    font-family: Consolas, Menlo, Monaco, Lucida Console, monospace;
}

pre {
    padding: 5px;
    background-color: #F8F8F8;
    border: 1px solid #CCC;
    width: 80%;
    margin: 0 auto;
    border-radius: 3px;
}

pre code {
    margin: 0;
    padding: 0;
}

blockquote {
    font-style: italic;
    width: 80%;
}