git.fiddlerwoaroof.com
Browse code

Fix proxied IP address . . . hopefully?

fiddlerwoaroof authored on 28/01/2016 07:46:50
Showing 7 changed files
... ...
@@ -23,6 +23,7 @@
23 23
   :serial t
24 24
   :components ((:file "package")
25 25
                (:file "tables")
26
+               (:file "web")
26 27
                (:file "inangulis")))
27 28
 
28 29
 ;; vim: set ft=lisp:
... ...
@@ -14,10 +14,6 @@
14 14
 (defun str-assoc (param params &key (test #'equal) key)
15 15
   (cdr-assoc param params :test test :key key))
16 16
 
17
-(defmacro setf1 (&body body)
18
-  "Make setf a bit nicer"
19
-  (list* 'setf (apply #'append body)))
20
-
21 17
 ;;; "inangulis" goes here. Hacks and glory await!
22 18
 
23 19
 (defun current-date-string ()
... ...
@@ -32,7 +28,9 @@
32 28
    (moderator :initarg :moderator :initform nil)))
33 29
 
34 30
 (defun user-alist (user)
35
-  (with-slots (email name moderator) user
31
+  (with-slots ((email inangulis.tables::email)
32
+               (name inangulis.tables::name)
33
+               (moderator inangulis.tables::moderator)) user
36 34
     `(("name" . ,name)
37 35
       ("email" . ,email)
38 36
       ("moderator" . ,moderator))))
... ...
@@ -71,6 +69,9 @@
71 69
   (remove-if (lambda (x) (not (equal x "approved"))) feeds :key #'s-approved))
72 70
 
73 71
 (defparameter *app* (make-instance 'ningle:<app>))
72
+(defmethod lack.component:call :before ((component (eql *app*)) env)
73
+  (awhen (gethash "x-remote-ip" (lack.request:request-headers ningle.context:*request*))
74
+    (setf (lack.request:request-remote-addr ningle:*request*) it)))
74 75
 
75 76
 (defmacro i-defun (name (&rest args) &body body)
76 77
   `(defun ,name (,@args)
... ...
@@ -84,12 +85,6 @@
84 85
      (declare (ignorable ,@args))
85 86
      ,@body))
86 87
 
87
-(defun render-mustache (fn data)
88
-  (with-open-file (s (truename fn))
89
-    (let ((template (make-string (file-length s))))
90
-      (read-sequence template s)
91
-      (mustache:render* template data))))
92
-
93 88
 (defmacro with-db (&body b)
94 89
   `(postmodern:with-connection (ubiquitous:value 'db)
95 90
      ,@b))
... ...
@@ -100,28 +95,12 @@
100 95
        ,@b)))
101 96
 
102 97
 
103
-(setf (ningle:route *app* "/")
104
-      (flet ((render-index (&optional user)
105
-               (render-mustache #p"static/index.mustache.html"
106
-                                (cons
107
-                                  `(:links . ,(mapcar #'submission-alist *submissions*))
108
-                                  (when user
109
-                                    (list
110
-                                      `(:user . ,(user-alist user))))))))
111
-        (i-lambda (params)
112
-          (with-submissions
113
-            (ningle.context:with-context-variables (session)
114
-              (handler-case
115
-                (cl-oid-connect.utils:ensure-logged-in
116
-                  (cl-oid-connect.utils:redirect-if-necessary session
117
-                    (render-index (gethash :app-user session))))
118
-                (cl-oid-connect.utils:user-not-logged-in (c) (render-index))))))))
98
+
119 99
 
120 100
 (defun submit (params)
121
-  (with-db
122
-    (awhen (alist-submission params :nil-if-exists t)
123
-      (postmodern:insert-dao it)
124
-      (push it *submissions*))))
101
+  (awhen (alist-submission params :nil-if-exists t)
102
+    (postmodern:insert-dao it)
103
+    (push it *submissions*)))
125 104
 
126 105
 (defun get-feed-guid (item)
127 106
   (with-slots (alimenta:title alimenta:link) item
... ...
@@ -130,27 +109,37 @@
130 109
       (ironclad:update-digest hasher (babel:string-to-octets alimenta:link))
131 110
       (ironclad:byte-array-to-hex-string (ironclad:produce-digest hasher)))))
132 111
 
112
+(defmethod run-route :around (name params &rest r)
113
+  (declare (ignore r))
114
+  (with-db
115
+    (pomo:with-transaction ()
116
+      (call-next-method))))
117
+
133 118
 ;; View Controllers
134
-(i-defun murmur (params)
119
+(define-controller murmur (params)
135 120
   (sleep 0.01)
136
-  (submit params)
137
-  '(302 (:location "/") ("Done")))
138
-
139
-(i-defun curate (params)
140
-  (with-db
141
-    (let ((*submissions* (postmodern:select-dao 'inangulis.tables::submission t "date desc")))
142
-      (cl-oid-connect.utils:require-login
143
-        (alet (alist-submission params)
144
-          (let ((approval (string-downcase (str-assoc "approved" params :test #'equalp))))
145
-            (setf (s-approved it)
146
-                  (if (equal approval "+") "approved"
147
-                    (if (equal approval "-") "rejected")))
148
-            (when *persist*
149
-              (postmodern:update-dao it)))))))
121
+  (submit params))
122
+
123
+(define-view murmur (model)
124
+  '(302 (:location "/") ("Done"))) 
125
+  
126
+(define-controller curate (params)
127
+  (let ((*submissions* (postmodern:select-dao 'inangulis.tables::submission t "date desc")))
128
+    (cl-oid-connect.utils:require-login
129
+      (alet (alist-submission params)
130
+        (let ((approval (string-downcase (str-assoc "approved" params :test #'equalp))))
131
+          (setf (s-approved it)
132
+                (if (equal approval "+") "approved"
133
+                  (if (equal approval "-") "rejected")))
134
+          (when *persist*
135
+            (postmodern:update-dao it)))))))
136
+
137
+(define-view curate (params)
150 138
   '(302 (:location "/") ("Done")))
151 139
 
152
-(i-defun login-page (params)
153
-  `(200 ()
140
+(define-view login-page (model)
141
+  `(200
142
+    ()
154 143
     (,(cl-who:with-html-output-to-string (s)
155 144
         (:html
156 145
           (:head
... ...
@@ -161,15 +150,17 @@
161 150
             (:div :class "login-buttons"
162 151
              (:a :class "facebook" :href "/login/facebook" "Login With Facebook"))))))))
163 152
 
164
-(i-defun logout (params)
153
+(define-controller logout (params)
165 154
   (ningle:with-context-variables (session)
166
-    (setf (gethash :userinfo session) nil)
167
-    '(302 (:location "/"))))
155
+    (setf (gethash :userinfo session) nil)))
168 156
 
169
-(i-defun get-feed (params &key moderated)
157
+(define-view logout (model)
158
+  '(302 (:location "/")))
159
+
160
+(defmethod controller ((name (eql 'get-feed)) params &key moderated)
170 161
   (let ((feed (alimenta::make-feed :title "In Angulis" :link "http://in-angulis.com/feed"
171 162
                                    :description "Locus in quo sunt illi qui murmurant in angulis")))
172
-    (with-db
163
+    (prog1 feed
173 164
       (pomo:do-select-dao (('inangulis.tables::submission submission)
174 165
                            (:raw (if moderated (pomo:sql (:= 'approved "approved")) "'t'"))
175 166
                            (:desc 'date))
... ...
@@ -178,29 +169,88 @@
178 169
                                     :link (s-url submission)
179 170
                                     :date (s-date submission)
180 171
                                     :next-id #'get-feed-guid
181
-                                    :content "")))
182
-    `(200 (:content-type "application/rss+xml") (,(plump:serialize (alimenta:generate-xml feed) nil)))))
183
-
184
-(setf1 ((ningle:route *app* "/feed" :method :GET) (lambda (params) (get-feed params :moderated t)))
185
-       ((ningle:route *app* "/firehose" :method :GET) #'get-feed)
186
-       ((ningle:route *app* "/login" :method :GET) #'login-page)
187
-       ((ningle:route *app* "/curo" :method :POST) #'curate)
188
-       ((ningle:route *app* "/murmuro" :method :POST) #'murmur)
189
-       ((ningle:route *app* "/logout") #'logout)) 
190
-
172
+                                    :content "")))))
173
+
174
+(defparameter *tmp* nil)
175
+
176
+(define-view get-feed (feed)
177
+  `(200 (:content-type "application/rss+xml") (,(plump:serialize (alimenta:generate-xml feed) nil))))
178
+
179
+(mustache-view root (user . links) #p"static/index.mustache.html"
180
+  :links (mapcar #'submission-alist links)
181
+  :user (when user (user-alist user)))
182
+
183
+(define-controller root (params)
184
+  (setf *tmp* ningle.context:*request*)
185
+  (with-submissions
186
+    (ningle:with-context-variables (session)
187
+      (handler-case
188
+        (cl-oid-connect.utils:ensure-logged-in
189
+          (cl-oid-connect.utils:redirect-if-necessary session
190
+            (cons (gethash :app-user session)
191
+                  *submissions*)))
192
+        (cl-oid-connect.utils:user-not-logged-in (c) (cons nil *submissions*))))))
193
+
194
+(defmethod controller :around ((name (eql 'headlines)) params &key (moderated t moderated-p))
195
+  (unless moderated-p
196
+    (awhen (str-assoc "moderated" params :test #'string-equal)
197
+           (setf moderated (and (> (length it) 0) (char= #\t (elt it 0)))
198
+                 moderated-p t)))
199
+  (if moderated-p
200
+    (call-next-method name params :moderated moderated)  
201
+    (call-next-method)))
202
+
203
+(defmethod controller ((name (eql 'headlines)) params &key (moderated t moderated-p))
204
+  (let (result)
205
+    (ningle.context:with-context-variables (session)
206
+      (let* ((app-user (gethash :app-user session))
207
+             (moderator-p (and app-user (slot-value app-user 'inangulis.tables::moderator))))
208
+        (pomo:do-select-dao (('inangulis.tables::submission submission)
209
+                             (:raw (cond 
210
+                                     ((and moderator-p (not moderated-p)) "'t'")
211
+                                     (moderated (pomo:sql (:= 'approved "approved")))
212
+                                     (t "'t'")))
213
+                             (:desc 'date))
214
+          (push submission result))))
215
+    result))
216
+
217
+(define-view headlines (columns) ;#p"static/headlines.mustache.html"
218
+  (let ((sub-len (length columns))
219
+        (columns (mapcar #'submission-alist columns)))
220
+    (render-mustache #p"static/headlines.mustache.html"
221
+                     `((:columns . (((:rows .  ,(subseq columns 0 (floor sub-len 3))))
222
+                                    ((:rows .  ,(subseq columns (floor sub-len 3) (* 2 (floor sub-len 3)) )))
223
+                                    ((:rows .  ,(subseq columns (* 2 (floor sub-len 3)) sub-len)))))))))
224
+
225
+(defroutes *app*
226
+  (("/") (as-route 'root))
227
+  (("/curo" :method :POST) (as-route 'curate)) 
228
+  (("/feed") (as-route 'get-feed :moderated t))
229
+  (("/firehose") (as-route 'get-feed))
230
+  (("/headlines") (as-route 'headlines)) 
231
+  (("/login") (as-route 'login-page))
232
+  (("/logout") (as-route 'logout)) 
233
+  (("/murmuro" :method :POST) (as-route 'murmur)))
191 234
 
192 235
 (cl-oid-connect::setup-oid-connect *app* (userinfo &rest args)
193 236
   (declare (ignore args))
194 237
   (let ((id (cdr (assoc :id userinfo))))
195
-    (unless (gethash id *users*)
196
-      (setf (gethash id *users*)
197
-            (alet (make-instance 'user)
198
-              (with-slots (uid name email) it
199
-                (prog1 it
200
-                  (setf uid id
201
-                        name (cdr (assoc :name userinfo))
202
-                        email (cdr (assoc :email userinfo))))))))
203
-    (gethash id *users*)))
238
+    (with-db
239
+      (aif (car (postmodern:select-dao 'inangulis.tables::user (:= 'uid id)))
240
+        it
241
+        (alet (make-instance 'inangulis.tables::user)
242
+          (with-slots ((uid inangulis.tables::uid)
243
+                       (name inangulis.tables::name)
244
+                       (email inangulis.tables::email)) it
245
+            (prog1 it
246
+              (setf uid id
247
+                    name (cdr (assoc :name userinfo))
248
+                    email (cdr (assoc :email userinfo)))
249
+              (pomo:with-transaction ()
250
+                (postmodern:insert-dao it)))))))))
251
+
252
+
253
+
204 254
 
205 255
 (let ((handler nil))
206 256
   (ubiquitous:restore :inangulis)
... ...
@@ -222,7 +272,7 @@
222 272
                          :backtrace
223 273
                          *app*
224 274
                          )
225
-                       :debug (ubiquitous:defaulted-value t 'debug))
275
+                      :debug (ubiquitous:defaulted-value t 'debug))
226 276
               :port 9090
227 277
               :server server)
228 278
             handler)))
... ...
@@ -4,6 +4,15 @@
4 4
   (:use #:cl #:anaphora #:postmodern)
5 5
   (:export #:submission #:headline #:url #:date #:approved))
6 6
 
7
+(defpackage #:inangulis.web
8
+  (:use #:cl)
9
+  (:export #:defroutes #:as-route #:define-controller #:define-view
10
+           #:controller #:view #:run-route #:mustache-view #:render-mustache
11
+           #:setf1))
12
+
7 13
 (defpackage #:inangulis
8
-  (:use #:cl #:anaphora))
14
+  (:use #:cl #:anaphora #:inangulis.tables #:inangulis.web))
15
+
16
+(defpackage #:inangulis-user
17
+  (:use #:cl #:inangulis.tables #:inangulis.web #:inangulis))
9 18
 
10 19
new file mode 100644
... ...
@@ -0,0 +1,110 @@
1
+<!DOCTYPE html>
2
+<html lang="en">
3
+  <head prefix="og: http://ogp.me/ns# fb: http://ogp.me/ns/fb# join-marrow: http://ogp.me/ns/fb/join-marrow#">
4
+    <base href="http://planet.joinmarrow.com" />
5
+    <meta property="og:type" content="join-marrow:headlines" />
6
+    <meta property="og:title" content="Current Headlines" />
7
+    <meta property="og:site_name" content="Catholic News" />
8
+    <meta property="og:url" content="http://planet.joinmarrow.com/headlines" />
9
+    <meta property="og:image" content="http://planet.joinmarrow.com/planet.joinmarrow.png" />
10
+    <meta property="og:image:width"  content="512" />
11
+    <meta property="og:image:height" content="512" />
12
+    <meta property="og:description" content="Headlines from a collection of Catholic blogs. Visit us to take the pulse of the Catholic internet" />
13
+    <meta property="fb:app_id" content="897925460261572" />
14
+    <base target="_blank" />
15
+    <meta http-equiv="refresh" content="900"> <!-- refresh every 15 minutes -->
16
+
17
+    <link rel="apple-touch-icon" sizes="57x57" href="/apple-touch-icon-57x57.png">
18
+    <link rel="apple-touch-icon" sizes="60x60" href="/apple-touch-icon-60x60.png">
19
+    <link rel="apple-touch-icon" sizes="72x72" href="/apple-touch-icon-72x72.png">
20
+    <link rel="apple-touch-icon" sizes="76x76" href="/apple-touch-icon-76x76.png">
21
+    <link rel="apple-touch-icon" sizes="114x114" href="/apple-touch-icon-114x114.png">
22
+    <link rel="apple-touch-icon" sizes="120x120" href="/apple-touch-icon-120x120.png">
23
+    <link rel="apple-touch-icon" sizes="144x144" href="/apple-touch-icon-144x144.png">
24
+    <link rel="apple-touch-icon" sizes="152x152" href="/apple-touch-icon-152x152.png">
25
+    <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-180x180.png">
26
+    <link rel="icon" type="image/png" href="/favicon-32x32.png" sizes="32x32">
27
+    <link rel="icon" type="image/png" href="/favicon-194x194.png" sizes="194x194">
28
+    <link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96">
29
+    <link rel="icon" type="image/png" href="/android-chrome-192x192.png" sizes="192x192">
30
+    <link rel="icon" type="image/png" href="/favicon-16x16.png" sizes="16x16">
31
+    <link rel="manifest" href="/manifest.json">
32
+    <link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
33
+    <meta name="msapplication-TileColor" content="#888888">
34
+    <meta name="msapplication-TileImage" content="/mstile-144x144.png">
35
+    <meta name="theme-color" content="#ffffff">
36
+
37
+    <meta charset="UTF-8">
38
+    <meta name="viewport" content="width=device-width, initial-scale=1">
39
+
40
+    <title>Catholic Feeds - Headlines</title>
41
+    <link rel="stylesheet" href="headline-style.css">
42
+    <link rel="stylesheet" href="light.css">
43
+    <!-- Piwik -->
44
+    <script type="text/javascript">
45
+      var _paq = _paq || [];
46
+      _paq.push(['trackPageView']);
47
+      _paq.push(['enableLinkTracking']);
48
+      (function() {
49
+       var u="//piwik.elangley.org/";
50
+       _paq.push(['setTrackerUrl', u+'piwik.php']);
51
+       _paq.push(['setSiteId', 1]);
52
+       var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
53
+       g.type='text/javascript'; g.async=true; g.defer=true; g.src=u+'piwik.js'; s.parentNode.insertBefore(g,s);
54
+       })();
55
+
56
+function colorflip() {
57
+  var lightEl = document.querySelector('link[href^=light]');
58
+  var darkEl = document.querySelector('link[href^=dark]');
59
+
60
+  if (lightEl !== null) {
61
+    lightEl.href = "dark.css"
62
+  } else {
63
+    darkEl.href = "light.css"
64
+  }
65
+};
66
+
67
+</script>
68
+<noscript><p><img src="//piwik.elangley.org/piwik.php?idsite=1" style="border:0;" alt="" /></p></noscript>
69
+<!-- End Piwik Code -->
70
+
71
+   </head>
72
+   <body>
73
+     <script type="text/javascript">
74
+       //do work
75
+
76
+     if (window.location.hash === "#dark") {
77
+       console.log('hi!');
78
+       colorflip();
79
+     }
80
+
81
+</script>
82
+<div id="fb-root"></div>
83
+<script>(function(d, s, id) {
84
+  var js, fjs = d.getElementsByTagName(s)[0];
85
+  if (d.getElementById(id)) return;
86
+  js = d.createElement(s); js.id = id;
87
+  js.src = "//connect.facebook.net/en_US/sdk.js#xfbml=1&version=v2.5&appId=897925460261572";
88
+  fjs.parentNode.insertBefore(js, fjs);
89
+  }(document, 'script', 'facebook-jssdk'));</script>
90
+
91
+<header>
92
+<button class="flip-button" onclick="colorflip()">&hellip;</button>
93
+<h1>Headlines</h1>
94
+</header>
95
+<main>
96
+{{#columns}}
97
+<div class="col">
98
+  {{#rows}}
99
+  <article data-date="{{date}}">
100
+  <a href="{{url}}" rel="nofollow">
101
+    <h2>{{headline}}</h2>
102
+    <span class="source">{{url}}</span>
103
+  </a>
104
+  </article>
105
+  {{/rows}}
106
+</div>
107
+{{/columns}}
108
+</main>
109
+   </body>
110
+ </html>
0 111
new file mode 100644
... ...
@@ -0,0 +1,52 @@
1
+<!DOCTYPE html>
2
+<html lang="en">
3
+  <head>
4
+    <meta charset="UTF-8">
5
+    <title>In Angulis</title>
6
+    <!--<link rel="stylesheet" href="/css/baseline.css">-->
7
+    <link rel="stylesheet" href="/static/css/main.css">
8
+  </head>
9
+  <body>
10
+  <h1>In Angulis</h1>
11
+  {{#user}}
12
+  <div class="userinfo">
13
+    <div class="user">{{name}}</div>
14
+    <div class="email">{{email}}</div>
15
+    <div class="mod">{{moderator}}</div>
16
+    <a id="logout" href="/logout">[Logout]</a>
17
+    </div>
18
+    {{/user}}
19
+    <main id="app">
20
+    <form action="/murmuro" id="submission" name="submission" method="POST"  v-on:submit="submit">
21
+      <input type="text" name="headline" placeholder="Headline" v-model="newLink.headline">
22
+      <input type="text" name="url" placeholder="URL" v-model="newLink.url">
23
+      <input type="submit" value="Go" title='Murmuro'>
24
+    </form>
25
+    </main>
26
+
27
+    <script src="/static/js/jquery.js"></script>
28
+    <script src="/static/js/jquery.formalize.js"></script>
29
+    <script src="/static/js/vue.js"></script>
30
+    <script type="text/javascript">
31
+    new Vue({
32
+      el: '#app',
33
+
34
+      methods: {
35
+        submit: function(e) {
36
+          e.preventDefault();
37
+          this.links.unshift(0,Object.create(this.newLink));
38
+          this.newLink = {};
39
+          return false;
40
+        }
41
+      },
42
+
43
+      data: {
44
+        newLink: {
45
+        }
46
+      }
47
+    });
48
+    </script>
49
+</body>
50
+       </html>
51
+
52
+
... ...
@@ -3,6 +3,18 @@
3 3
 (eval-when (:compile-toplevel :load-toplevel :execute)
4 4
   (local-time:set-local-time-cl-postgres-readers))
5 5
 
6
+(defclass user ()
7
+  ((id :col-type serial)
8
+   (uid :initarg :uid :col-type text)
9
+   (email :initarg :email :col-type text)
10
+   (name :initarg :name :col-type text)
11
+   (moderator :initarg :moderator :initform nil :col-type bool))
12
+  (:metaclass dao-class)
13
+  (:keys id))
14
+
15
+(deftable user
16
+  (!dao-def))
17
+
6 18
 (defclass submission ()
7 19
   ((headline :initarg :headline :col-type text :initform ""  :accessor inangulis::s-headline)
8 20
    (url      :initarg :url      :col-type text :initform ""  :accessor inangulis::s-url)
... ...
@@ -15,6 +27,10 @@
15 27
   (!dao-def))
16 28
 
17 29
 ;; (ubiquitous:restore :inangulis)
30
+
31
+;; (with-connection (ubiquitous:value 'db)
32
+;;   (create-table 'user))
33
+
18 34
 ;; (with-connection (ubiquitous:value 'db)
19 35
 ;;   (create-table 'submission))
20 36
 
21 37
new file mode 100644
... ...
@@ -0,0 +1,61 @@
1
+;; This is a minimal web-framework built on ningle that attempts to decouple
2
+;; views from controllers and make life more interesting.
3
+(in-package :inangulis.web)
4
+
5
+(defgeneric controller (name params &key))
6
+(defgeneric view (name model))
7
+(defgeneric run-route (name params &rest r))
8
+
9
+(defmacro setf1 (&body body)
10
+  "Make setf a bit nicer"
11
+  (list* 'setf (apply #'append body)))
12
+
13
+(defmacro defroutes (app &body routes)
14
+  (alexandria:once-only (app)
15
+    (list* 'setf1
16
+           (loop for ((target &key method) callback) in routes
17
+                 collect `((ningle:route ,app ,target :method ,(or method :GET)) ,callback)))))
18
+
19
+ 
20
+(defmacro as-route (name &rest r &key &allow-other-keys)
21
+  `(lambda (params) (run-route ,name params ,@r)))
22
+
23
+
24
+(defmethod run-route (name params &rest r)
25
+  (view name (apply #'controller (list* name params r))))
26
+
27
+(defmethod controller (name params &key)
28
+  params)
29
+
30
+(defmacro define-controller (name (params &rest r &key &allow-other-keys) &body body)
31
+  `(defmethod controller ((name (eql ',name)) ,params &key ,@r)
32
+     ,@body))
33
+
34
+(defmacro define-view (name (model) &body body)
35
+  `(defmethod view ((name (eql ',name)) ,model)
36
+     ,@body))
37
+
38
+(defun render-mustache (fn data)
39
+  (with-open-file (s (truename fn))
40
+    (let ((template (make-string (file-length s))))
41
+      (read-sequence template s)
42
+      (mustache:render* template data))))
43
+
44
+
45
+(defmacro mustache ((template lambda-list data) &body body)
46
+  "Template specifies the template to be render, lambda-list is used to destructure data
47
+   and body transforms the destructured data into an alist for use in the template"
48
+  (alexandria:once-only (template)
49
+    `(destructuring-bind ,lambda-list ,data
50
+       (render-mustache ,template
51
+                        (list
52
+                          ,@(loop for (k v) on body by #'cddr
53
+                                  if (or k v)
54
+                                  collect `(cons ,k ,v)))))))
55
+
56
+(defmacro mustache-view (name lambda-list template &body body)
57
+  (alexandria:with-gensyms (model)
58
+    `(define-view ,name (,model)
59
+       (mustache (,template ,lambda-list ,model)
60
+         ,@body))))
61
+