216 lines
17 KiB
Plaintext
216 lines
17 KiB
Plaintext
<p>The <a href="https://fennel-lang.org">Fennel programming
|
|
language</a> recently celebrated its fifth birthday, and we ran
|
|
<a href="https://fennel-lang.org/2021">a survey</a> to learn more
|
|
about the community and what has been working well and what
|
|
hasn't. Fennel's approach has always been one of simplicity; not
|
|
just in the conceptual footprint of the language, but in reducing
|
|
dependencies and moving parts, and using on a runtime that fits in
|
|
under 200kb. In order to reflect this, the Fennel web site is hosted
|
|
as static files on the same Apache-backed shared hosting account
|
|
I've been using for this blog since 2005.</p>
|
|
|
|
<p>Of course, <a href="https://git.sr.ht/~technomancy/fennel-lang.org/tree/main/item/main.fnl">generating
|
|
HTML from lisp code</a> is one of the oldest tricks in the
|
|
book[<a href="https://technomancy.us#fn1">1</a>], so I won't bore anyone with the details there. But what
|
|
happens when you want to mix in something that <em>isn't</em>
|
|
completely static, like this survey? Well, that's where it gets interesting.</p>
|
|
|
|
<blockquote>
|
|
I put the shotgun in an Adidas bag and padded it out with four pairs
|
|
of tennis socks, not my style at all, but that was what I was aiming
|
|
for: If they think you're crude, go technical; if they think you're
|
|
technical, go crude. I'm a very technical boy. So I decided to get as
|
|
crude as possible. These days, though, you have to be pretty
|
|
technical before you can even aspire to crudeness.[<a href="https://technomancy.us#fn2">2</a>]
|
|
</blockquote>
|
|
|
|
<p>When I was in school, I learned how to write and deploy Ruby web
|
|
programs. The easiest way to get that set up was
|
|
using <a href="https://en.wikipedia.org/wiki/Common_Gateway_Interface">CGI</a>. A CGI script is just a process which is launched by the
|
|
web server in such a way that the request comes in on stdin and
|
|
environment variables and the response is sent over stdout. But
|
|
larger Ruby programs tended to have very slow boot times, which
|
|
didn't fit very well with CGI's model of launching a process afresh
|
|
for every request that came in, and eventually other models replaced
|
|
CGI. Most people regard CGI as somewhat outmoded and obsolete, but
|
|
it fits Fennel's ethos nicely and complements a mostly-static-files
|
|
approach.</p>
|
|
|
|
<p>So the
|
|
survey <a href="https://git.sr.ht/~technomancy/fennel-lang.org/tree/3bed58d0007ac8f9616486ef20094cffc2c10562/item/survey/survey.fnl#L39">generates
|
|
an HTML form</a> in a static file which points to a CGI script as
|
|
its <tt>action</tt>. The CGI script looks like this, but it gets
|
|
compiled to Lua as part of the deploy process to keep the
|
|
dependencies light.</p>
|
|
|
|
<pre class="code">(<span class="keyword">let</span> [contents (<span class="type">io.read</span> <span class="string">"*all"</span>)
|
|
date (<span class="type">io.popen</span> <span class="string">"date --rfc-3339=ns"</span>)
|
|
id (<span class="keyword">:</span> (date<span class="builtin">:read</span> <span class="string">"*a"</span>) <span class="builtin">:sub</span> 1 -2)]
|
|
(<span class="keyword">with-open</span> [raw (<span class="type">io.open</span> (<span class="keyword">..</span> <span class="string">"responses/"</span> id <span class="string">".raw"</span>) <span class="builtin">:w</span>)]
|
|
(raw<span class="builtin">:write</span> contents))
|
|
(<span class="builtin">print</span> <span class="string">"status: 301 redirect"</span>)
|
|
(<span class="builtin">print</span> <span class="string">"Location: /survey/thanks.html\n"</span>))</pre>
|
|
|
|
<p>As you can see, all this does is read the request body
|
|
using <tt>io.read</tt>, create a file with the current timestamp
|
|
as the filename (we shell out to <tt>date</tt> because the
|
|
built-in <tt>os.time</tt> function lacks subsecond resolution) and
|
|
prints out a canned response redirecting the browser to another
|
|
static HTML page. We could have printed HTML for the response body,
|
|
but why complicate things?</p>
|
|
|
|
<p>At this point we're all set as far as gathering data goes. But what
|
|
do we do with these responses? Well, a typical approach would be to
|
|
write them to a database rather than the filesystem, and to create
|
|
another script which reads from the database whenever it gets an
|
|
HTTP request and emits HTML which summarizes the results. You could
|
|
certainly do this in Fennel
|
|
using <a href="https://openresty.org/en/postgres-nginx-module.html">nginx
|
|
and its postgres</a> module, but it didn't feel like a good fit for
|
|
this. A database has a lot of moving parts and complex features
|
|
around consistency during concurrent writes which are simply
|
|
astronomically unlikely[<a href="https://technomancy.us#fn3">3</a>] to happen in this
|
|
case.</p>
|
|
|
|
<p>At this point I think it's time to take a look at the <tt>Makefile</tt>:</p>
|
|
|
|
<pre class="code"><span class="makefile-targets">upload</span>: index.html save.cgi thanks.html 2021.html
|
|
<span class="makefile-targets">rsync -rAv </span><span class="makefile-targets"><span class="makefile-targets">$</span></span><span class="makefile-targets"><span class="constant"><span class="makefile-targets">^</span></span></span><span class="makefile-targets"> fennel-lang.org</span>:fennel-lang.org/survey/
|
|
|
|
<span class="makefile-targets">index.html</span>: survey.fnl questions.fnl
|
|
../fennel/fennel --add-fennel-path <span class="string">"../?.fnl"</span> $<span class="constant"><</span> > <span class="makefile-targets"><span class="makefile-targets">$</span></span><span class="makefile-targets"><span class="makefile-targets"><span class="constant">@</span></span></span>
|
|
|
|
<span class="makefile-targets">save.cgi</span>: save.fnl
|
|
echo <span class="string">"#!/usr/bin/env lua"</span> > <span class="makefile-targets"><span class="makefile-targets">$</span></span><span class="makefile-targets"><span class="makefile-targets"><span class="constant">@</span></span></span>
|
|
../fennel/fennel --compile $<span class="constant"><</span> >> <span class="makefile-targets"><span class="makefile-targets">$</span></span><span class="makefile-targets"><span class="makefile-targets"><span class="constant">@</span></span></span>
|
|
chmod 755 <span class="makefile-targets"><span class="makefile-targets">$</span></span><span class="makefile-targets"><span class="makefile-targets"><span class="constant">@</span></span></span>
|
|
|
|
<span class="makefile-targets">pull</span>:
|
|
<span class="makefile-targets">rsync -rA fennel-lang.org</span>:fennel-lang.org/survey/responses/ responses/
|
|
|
|
<span class="makefile-targets">2021.html</span>: summary.fnl chart.fnl questions.fnl responses/* commentary/2021/*
|
|
../fennel/fennel --add-fennel-path <span class="string">"../?.fnl"</span> $<span class="constant"><</span> > <span class="makefile-targets"><span class="makefile-targets">$</span></span><span class="makefile-targets"><span class="makefile-targets"><span class="constant">@</span></span></span></pre>
|
|
|
|
<p>So the <tt>pull</tt> target takes all the raw response files from
|
|
the server and brings them into my local checkout of the web site on
|
|
my laptop. The <tt>2021.html</tt> target runs
|
|
the <tt>summary.fnl</tt> script locally to read thru all the
|
|
responses, parse them, aggregate them, and emit static HTML
|
|
containing inline SVG charts. Then the <tt>upload</tt> task puts the
|
|
output back on the server. Here's the code which takes that raw form
|
|
data from the CGI script and turns it into a data structure[<a href="https://technomancy.us#fn4">4</a>]:</p>
|
|
|
|
<pre class="code"><span class="region">(</span><span class="keyword"><span class="region">fn</span></span><span class="region"> </span><span class="function-name"><span class="region">parse</span></span><span class="region"> [contents] </span><span class="comment"><span class="region">; for form-encoded data
|
|
</span></span><span class="region"> (</span><span class="keyword"><span class="region">fn</span></span><span class="region"> </span><span class="function-name"><span class="region">decode</span></span><span class="region"> [str] (str</span><span class="builtin"><span class="region">:gsub</span></span><span class="region"> </span><span class="string"><span class="region">"%%(%x%x)"</span></span><span class="region"> (</span><span class="keyword"><span class="region">fn</span></span><span class="region"> [v] (</span><span class="type"><span class="region">string.char</span></span><span class="region"> (</span><span class="builtin"><span class="region">tonumber</span></span><span class="region"> v 16)))))
|
|
(</span><span class="keyword"><span class="region">let</span></span><span class="region"> [out {}]
|
|
(</span><span class="keyword"><span class="region">each</span></span><span class="region"> [k v (contents</span><span class="builtin"><span class="region">:gmatch</span></span><span class="region"> </span><span class="string"><span class="region">"([^&=]+)=([^&=]+)"</span></span><span class="region">)]
|
|
(</span><span class="keyword"><span class="region">let</span></span><span class="region"> [key (decode (k</span><span class="builtin"><span class="region">:gsub</span></span><span class="region"> </span><span class="string"><span class="region">"+"</span></span><span class="region"> </span><span class="string"><span class="region">" "</span></span><span class="region">))]
|
|
(</span><span class="keyword"><span class="region">when</span></span><span class="region"> (</span><span class="keyword"><span class="region">not</span></span><span class="region"> (</span><span class="keyword"><span class="region">.</span></span><span class="region"> out key))
|
|
(</span><span class="keyword"><span class="region">tset</span></span><span class="region"> out key []))
|
|
(</span><span class="type"><span class="region">table.insert</span></span><span class="region"> (</span><span class="keyword"><span class="region">.</span></span><span class="region"> out key) (</span><span class="keyword"><span class="region">pick-values</span></span><span class="region"> 1 (decode (v</span><span class="builtin"><span class="region">:gsub</span></span><span class="region"> </span><span class="string"><span class="region">"+"</span></span><span class="region"> </span><span class="string"><span class="region">" "</span></span><span class="region">))))))
|
|
out))</span></pre>
|
|
|
|
<p>The final piece I want to mention is the charts in the survey
|
|
results. I wasn't sure how I'd visualize the results, but I had some
|
|
experience writing SVG from
|
|
my <a href="https://gitlab.com/technomancy/atreus/-/blob/master/case/case.rkt">programmatically
|
|
generated keyboard cases</a> I had constructed on my laser
|
|
cutter. If you've never looked closely at SVG before, it's a lot
|
|
more accessible than you might expect. This code takes the data from
|
|
the previous function after it's been aggregated by response count
|
|
and emits a bar chart with counts for each response. Here's an
|
|
example of one of the charts; inspect the source to see how it looks
|
|
if you're curious:</p>
|
|
|
|
<svg class="chart" height="105" width="900" xmlns="http://www.w3.org/2000/svg">
|
|
<g class="bar">
|
|
<rect height="20" width="430" y="0"></rect>
|
|
<text dy="0.35em" x="435" y="12">Linux-based (43)</text>
|
|
</g>
|
|
<g class="bar">
|
|
<rect height="20" width="110" y="21"></rect>
|
|
<text dy="0.35em" x="115" y="33">MacOS (11)</text>
|
|
</g>
|
|
<g class="bar">
|
|
<rect height="20" width="60" y="42"></rect>
|
|
<text dy="0.35em" x="65" y="54">Windows (6)</text>
|
|
</g>
|
|
<g class="bar">
|
|
<rect height="20" width="40" y="63"></rect>
|
|
<text dy="0.35em" x="45" y="75">Other BSD-based (4)</text>
|
|
</g>
|
|
<desc id="desc-5">Linux-based: 43, MacOS: 11, Windows: 6, Other
|
|
BSD-based: 4</desc>
|
|
</svg>
|
|
|
|
<p>I had never tried putting SVG directly into HTML before, but I
|
|
found you can just embed an <svg> element like any other. The
|
|
<desc> elements even allow it to be read by a screen reader.</p>
|
|
|
|
<pre class="code">(<span class="keyword">fn</span> <span class="function-name">bar-rect</span> [answer count i]
|
|
(<span class="keyword">let</span> [width (<span class="keyword">*</span> count 10)
|
|
y (<span class="keyword">*</span> 21 (<span class="keyword">-</span> i 1))]
|
|
[<span class="builtin">:g</span> {<span class="builtin">:class</span> <span class="builtin">:bar</span>}
|
|
[<span class="builtin">:rect</span> {<span class="keyword">:</span> width <span class="builtin">:height</span> 20 <span class="keyword">:</span> y}]
|
|
[<span class="builtin">:text</span> {<span class="builtin">:x</span> (<span class="keyword">+</span> 5 width) <span class="builtin">:y</span> (<span class="keyword">+</span> y 12) <span class="builtin">:dy</span> <span class="string">"0.35em"</span>}
|
|
(<span class="keyword">..</span> answer <span class="string">" ("</span> count <span class="string">")"</span>)]]))
|
|
|
|
(<span class="keyword">fn</span> <span class="function-name">bar</span> [i data ?sorter]
|
|
<span class="comment-delimiter">;; </span><span class="comment">by default, sort in descending order of count of responses, but
|
|
</span> <span class="comment-delimiter">;; </span><span class="comment">allow sorting to be overridden, for example with the age question
|
|
</span> <span class="comment-delimiter">;; </span><span class="comment">the answers should be ordered by the age, not response count.
|
|
</span> (<span class="keyword">fn</span> <span class="function-name">count-sorter</span> [k1 k2]
|
|
(<span class="keyword">let</span> [v1 (<span class="keyword">.</span> data k1) v2 (<span class="keyword">.</span> data k2)]
|
|
(<span class="keyword">if</span> (<span class="keyword">=</span> v1 v2) (<span class="keyword"><</span> k1 k2) (<span class="keyword"><</span> v2 v1))))
|
|
(<span class="keyword">let</span> [sorter (<span class="keyword">or</span> ?sorter count-sorter)
|
|
answers (<span class="keyword">doto</span> (<span class="keyword">icollect</span> [k (<span class="builtin">pairs</span> data)] k) (<span class="type">table.sort</span> sorter))
|
|
svg [<span class="builtin">:svg</span> {<span class="builtin">:class</span> <span class="builtin">:chart</span> <span class="builtin">:role</span> <span class="builtin">:img</span>
|
|
<span class="builtin">:aria-label</span> <span class="string">"bar graph"</span> <span class="builtin">:aria-describedby</span> (<span class="keyword">..</span> <span class="string">"desc-"</span> i)
|
|
<span class="builtin">:width</span> 900 <span class="builtin">:height</span> (<span class="keyword">*</span> 21 (<span class="keyword">+</span> 1 (<span class="keyword">length</span> answers)))}]
|
|
descs []]
|
|
(<span class="keyword">each</span> [i answer (<span class="builtin">ipairs</span> answers)]
|
|
(<span class="type">table.insert</span> svg (bar-rect answer (<span class="keyword">.</span> data answer) i))
|
|
(<span class="type">table.insert</span> descs (<span class="keyword">..</span> answer <span class="string">": "</span> (<span class="keyword">.</span> data answer))))
|
|
(<span class="type">table.insert</span> svg [<span class="builtin">:desc</span> {<span class="builtin">:id</span> (<span class="keyword">..</span> <span class="string">"desc-"</span> i)} (<span class="type">table.concat</span> descs <span class="string">", "</span>)])
|
|
svg))
|
|
|
|
{<span class="keyword">:</span> bar}</pre>
|
|
|
|
<p>In the end, other than the
|
|
actual <a href="https://git.sr.ht/~technomancy/fennel-lang.org/tree/main/item/survey/questions.fnl">questions</a>
|
|
of the survey, all the code clocked in at just over 200 lines. If
|
|
you're curious to read thru the whole thing you can find it
|
|
in <a href="https://git.sr.ht/~technomancy/fennel-lang.org/tree/main/item/survey/">the
|
|
survey/ subdirectory of the fennel-lang.org repository</a>.</p>
|
|
|
|
<p>As you can see
|
|
from <a href="https://fennel-lang.org/survey/2021">reading the
|
|
results</a>, one of the things people wanted to see more of with Fennel
|
|
was some detailed example code. So hopefully this helps with that,
|
|
and people can learn both about how the code is put together and the
|
|
unusual approach to building it out.</p>
|
|
|
|
<hr />
|
|
|
|
<p>[<a name="fn1">1</a>] In fact,
|
|
the <a href="https://git.sr.ht/~technomancy/fennel-lang.org/tree/main/item/html.fnl">HTML
|
|
generator code</a> which is used for Fennel's web site was written
|
|
in 2018 at <a href="https://conf.fennel-lang.org/2018">the first FennelConf</a>.</p>
|
|
|
|
<p>[<a name="fn2">2</a>] from <i>Johnny Mnemonic</i> by William Gibson</p>
|
|
|
|
<p>[<a name="fn3">3</a>] If we <em>had</em> used <tt>os.time</tt>
|
|
with its second-level granularity instead of <tt>date</tt> with
|
|
nanosecond precision then concurrent conflicting writes would have
|
|
moved from astronomically unlikely to merely very, very unlikely,
|
|
with the remote possibility of two responses overwriting each other
|
|
if they arrived within the same second. We had fifty responses over
|
|
a period of 12 days, so this never came close to happening, but in
|
|
other contexts it could have, so choose your data storage mechanism
|
|
to fit the problem at hand.</p>
|
|
|
|
<p>[<a name="fn4">4</a>] This code is actually taken from the code I
|
|
wrote a couple years ago to handle signups
|
|
for <a href="https://conf.fennel-lang.org/2019">FennelConf 2019</a>.
|
|
If I wrote it today I would have made it use the <tt>accumulate</tt>
|
|
or <tt>collect</tt> macros.</p> |