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%;
}