购入 eltr.ac 域名之后,我打算把「个人身份」和「作品」分开,也就是用 eltr.ac 域名代表我在互联网上的身份标识,用 geedea.pro(也就是这个博客的域名)代表「极客死亡计划」这个作品。是的,我并不打算把我的博客当作我网络身份的载体,更多地是视作独立的作品。目前我的联邦宇宙实例用的是 eltr.ac 域名,我的 Matrix(一个去中心化的即时通信协议)服务器也是用这个域名,电子邮箱也是这个域名,所以就有了:
@eltrac@eltr.ac(我的联邦宇宙账号)
@eltrac:eltr.ac(我的 Matrix 账号)
hi@eltr.ac(我的电子邮箱地址)
不过,要是有有心人尝试访问 www.eltr.ac,只能看到一个很简陋的页面。既然要把这个域名用作身份标识,那在万维网层也要有一个足够有个性的页面吧,就算仅仅是当作导航也好。
编程语言的选择
一开始我用
Hugo
做了一个简陋的网站,还参考
slashpages.net
添加了一些足够「个人化」的页面,但总觉得不满意。仔细想想,这竟然已经是我的第三个 Hugo 网站了!第一个是这个博客,第二个是这个博客的
后花园
。尽管我很喜欢 Hugo,这个用 Go 语言开发的静态网站生成器也是全世界最快的生成器,但也不能用 Hugo 解决所有的网站构建需求吧?况且,我只需要几个页面,用非常擅长处理大量 Markdown 文件的工具来构建网站,属实有点像「用大炮打蚊子」。再者,我也对 Go HTML Template 感到厌烦了。
简单的、不常修改的静态页面,要是用我熟悉的 Vue.js(或者…… React)来做,也有过度工程之嫌。貌似手写 HTML 就能胜任,不过要是真这么做,那我还不如直接加入
Neocities
呢。之前被迫写了太多 Java 代码,也不可避免地接触到了很多 XML(Java 的项目管理工具 Maven,配置文件就是用 XML 写的;还有老旧的 MyBatis,编写数据表和 Java Bean 的隐射也要写 mapper.xml),继而对 HTML 也感到厌烦了。自动补全固然好用,但难道就没有更优雅的解决方案了吗!
前端开发者应该都熟悉「DOM 树」的概念,HTML 所代表的就是一种树形结构,根节点是 <html> 标签,<head> 和 <body> 组成了第一级的子节点,然后继续往下生长。这样想就很简单了,现代编程语言里能够代表树的数据类型多了去了,只要用某种语言先编写好 DOM 树的结构,再转换成 HTML 字符串就好了。那么,能够表示 DOM 树的最简单的结构是什么呢?
广义表就是个例子,这是数据结构里的概念,更接地气的名字是数组、元组和列表。比如,用 JavaScript 就可以这样写:
const html = [
{ name: "head", innerHTML: [
{ name: "meta", attribute: {
rel: "stylesheet",
href: "/main.css"
} }
]},
{ name: "body", innerHTML: [ ... ] }
]
停停停,用 JavaScript 数组和对象写 HTML,还不如直接手写呢。有没有办法全部简化成数组(列表),不使用对象呢?比如这样:
const html = ["html",
["head", [ ... ]],
["body", [
["p",
[ "class", "paragraph font-sans",
"id", "paragraph" ],
"Hello World!"
],
// ...
]]
]
你等一下,你再继续写下去就是在抄袭别人的 Lisp 传教文了。
是的,既然我们已经知道嵌套的列表实际上就是树的结构,那为什么不可以直接用列表来写 DOM 树呢?既然我们已经把编写 HTML 页面变成了对列表的处理,那四舍五入就得到了「列表处理」,List Processing,所以,答案就是 Lisp!
老实说,除了我本来就对这门编程界的拉丁语感兴趣之外,促使我学习一门 Lisp 方言的最大推动力就是:用它来写网页的方式简直是太优雅,太天才了! 这下谁还需要依赖 Tree-sitter 和编辑器插件自动闭合 HTML 标签?还有那可恶的 JSX,也可以说再见了。现在只需要闭合一大堆没完没了的括号就好了。
技术栈的选择
我选择的 Lisp 方言是
Clojure
,至于为什么选它,原因是…… 呃,
矩阵
上有一只
狐狸
诱拐我进了这个兔子洞。无论如何,我就先从 Clojure 开始用着吧。要使用 Clojure 编写程序,第一步是——
安装 Java?!
没错,Clojure 是运行在 JVM(Java 虚拟机)上的,这下再讨厌 Java 都逃不掉了,不过也还好,不需要亲手碰肮脏的 Java 代码,还能享受偷窃 Java 库来用的快感。Minecraft 玩家们的电脑里应该都有 Java,所以可以跳过这一步了。
最常用的 Clojure 开发工具是
Leiningen
,用来创建项目、管理依赖、运行和编译项目等等。要将 Clojure 的数据结构转换为 HTML,可以使用
Hiccup
这个库。既然我们都用 Lisp 写 HTML 了,那为什么不用 Lisp 写 CSS 呢?于是我很快发现了
Garden
这个库,用于把 Clojure 转译为 CSS。
现在准备就绪了,开始创建项目吧!
顺带一提,我准备把这个新网站命名为 capricorn(摩羯座),至于原因,仅仅是因为它是十二星座里首字母和 Clojure 相同的一个。
lein new app capricorn
cd capricorn
nvim project.clj
编辑 project.clj,把需要的依赖添加进去,也就是刚才提到的 Hiccup 和 Garden。
:dependencies [
[org.clojure/clojure "1.12.2"]
[hiccup "2.0.0"]
[garden "1.3.10"]]
哦对了,如果你还不了解 Lisp 的语法,让我花点时间来给你解释一下:列表的第一个元素是函数名,剩下的都是参数,比如 (println "Hello World") 就是打印 Hello World 字符串。在 Clojure 里,列表有多种类型,除了最基本的作为程序结构的 (),Clojure 还用其他的括号表示不同的数据容器:'() 是列表,[] 是向量,{} 是映射(也就是键值对),#{} 是集合。Clojure 里还有一种特殊的数据类型叫作「关键字」,写成这样 :keyword,我喜欢把它理解为不需要提前定义的枚举类型。
好了,现在你可以理解 Clojure 程序了,我们继续吧。
手搓静态网站生成器
静态网站生成器(Static Site Generator,SSG)这个东西可谓是遍地开花,
Cryogen
就是用 Clojure 写的一个 SSG。不过,由于我的需求不是额外制作一个博客,不需要将 Markdown 文件或 Org-Mode 文件构建为 HTML 的功能,我只需要把 Clojure 的数据结构编译成 HTML 字符串,然后写入一个文件即可。听起来不难,所以动手自己写吧!
用 Hiccup 编写 HTML 结构非常简单,以下是一个例子:
(require '[hiccup2.core :as h])
(str (h/html [:span {:class "foo"} "bar"]))
看起来有点难懂,没事,拆开来就好了:
require 显然是引入,我们这里把 Hiccup 的核心库以 h 这个名字引入了。
str 是 Clojure 标准库里的函数,顾名思义,就是用来把其他数据类型转换为字符串的。
h/html 中的 h 是我们刚才引入的 Hiccup 核心库,html 是这个库提供的函数,用于把 Clojure 的数据结构转换为 HTML 表示;这里接收一个向量类型的数据,也就是 [] 包裹的列表。
[:span ...] 显然是 <span> 标签的表示,这个向量由三部分组成:
:span 是我们刚才提到的关键词类型,这里作为 HTML 标签名。
{:class "foo"} 是一个映射,:class 关键词是键(key),"foo" 字面量是值(value),这个映射会被解析为 class="foo";我们还可以继续写更多的属性,比如 {:class "foo" :id "bar" :width 200},奇数个元素是键,紧跟其后的偶数个元素就是对应的值。
"bar" 是这个 <span> 标签的内容,也可以替换成向量,也就是继续嵌套 HTML 结构。
总而言之,这段 Clojure 代码构建了形如 <span class="foo">bar</span> 的 HTML 结构,并将它转换为字符串输出。Hiccup 还提供了非常好用的语法糖,如果要编写 <span class="foo" id="bar" />,可以写成 [:span.foo#bar]。 尽管看起来有点奇怪,但你很快就会意识到这样写 HTML 有多爽了。
我们先来解决一个关键的问题,静态网站生成器除了把某种形式的数据转换为 HTML 字符串,还应该把得到的结果写入 .html 文件。接下来我们要研究一下如何用 Clojure 进行 I/O 操作,文档里写的是使用 spit 函数将内容写入文件,然后……
等等,就没了?!
看起来是这样的,在 Clojure 里,你不需要创建 Writer,也不需要创建什么 File 对象,也不用额外引入一个 I/O 库1,你只需要使用 spit 函数,把内容「吐」出来。这个函数接收两个参数,第一个参数是文件路径名,第二个参数是要写入的内容。所以,我们可以编写这样的 Clojure 程序:
(defn -main
[& args]
(spit "public/index.html"
(str (h/html
[:p "Hello World"]))))
接下来,运行 lein run,然后你就会得到一个内容为 <p>Hello World</p> 的 index.html 文件。2
正式开发之前,我们大概是需要一个跑在本地的 Web 服务器的。如果是 JavaScript 技术栈,可以用 npm run dev 或者 pnpm dev 这样的命令。这样一个有些原始的项目应该怎么办呢?
cd public
python -m http.server 8080
open http://localhost:8080
There you go.
如果不介意没有热重载,每次更改之后需要手动运行程序然后手动刷新浏览器的话,就已经可以上路了。
模板!组件!
我们先把头拆出来吧。
(def head
[:head
[:title "Eltrac"]
[:meta {:charset "utf-8"}]
[:meta {:name "viewport" :content "width=device-width,initial-scale=1"}]])
def 是 Clojure 里定义符号(变量)的方式;如果要定义函数,就用 defn。由于我这个小网站只有几个页面,不在乎标题的话,<head> 可以直接复用,所以就不需要定义成函数了。
再把刚才的 Hello World 放到 <body> 里。由于不同页面的内容肯定是不一样的,所以把 body 定义为函数比较合适。
(defn body [content]
[:body
[:main content]])
然后拼接在一起,放在 <html> 里,最后再转成字符串写入文件。
(defn -main
[& args]
(spit "public/index.html"
(str (h/html
(h/raw "<!DOCTYPE html>")
[:html head (body
[:p "Hello World!"])]))))
其中 h/raw 函数可以防止 Hiccup 自动将 < 和 > 转义,用来声明 DOCTYPE 是必要的。最后就得到了:
<!DOCTYPE html>
<html>
<head>
<title>Eltrac</title>
<meta charset="utf-8" />
<meta content="width=device-width,initial-scale=1" name="viewport" />
</head>
<body>
<main>
<p>Hello World!</p>
</main>
</body>
</html>
显然,完全不需要什么 defineProps(),也不用额外学习怎么在父子组件之间传递数据。在 Clojure 和 Hiccup 的世界里,页面组件就是函数。怎么给函数传参数,就怎么给组件传参数。我们直接用 Clojure 处理向量,最后再把向量们汇总在一起,用 Hiccup 处理为 HTML,再转成字符串输出到文件里。这样一来,我们就能用 Lisp 编写 HTML 页面了。
Build website with Style
接下来要给网站添加 CSS,使用之前添加的 Garden 库。这个库的语法和 Hiccup 非常相似,最关键的函数是 css 函数,和前面的 html 函数一样,它接收向量作为参数,输出的是 CSS 字符串。
我一直是行内 CSS 的信奉者,少一个 .css 文件能减少服务器需要传送的文件数量,并且不会被缓存,用户能够在网站更新后立刻看到改动。只要 CSS 文件不是太大,我都建议使用行内 CSS,也就是 <style> 标签。
那给这个 Clojure 静态网站添加样式就非常简单了,只需要在 head 变量里加一个 <style> 标签,为了方便,我们再定义一个 style 变量。
(require '[garden.core :as g])
(def style (g/css
[:body {:margin 0}]) )
(def head
[:head
[:title "Eltrac"]
[:meta {:charset "utf-8"}]
[:meta {:name "viewport" :content "width=device-width,initial-scale=1"}]
[:style style]])
然后就可以愉快地用 Lisp 写 CSS 了!至于 JavaScript,我暂时还不打算添加。如果你需要,可以使用
ClojureScript
,也就是用 Clojure 来写 JavaScript。这样一来就能用 Clojure 写遍 HTML、CSS 和 JavaScript,实在是惬意。
生成多个文件
刚刚我们把所有的 HTML 汇总到了 main 函数里,生成了一个 index.html,一个网站显然不能只有一个页面。我们需要这样的结构:
(def page_index (base [:p "Hey!"]) )
(def page_contact (base [:p "Contact me!"]))
(defn -main [& args]
(site {:index page_index
:contact page_contact}))
简单来说,我们在主函数里调用名为 site 的函数,传入一个映射,映射的键是页面名字,值是对应的 HTML 结构。定义 HTML 结构时还用到了自定义的一个 base 函数,理解成模板就好了。
site 是怎么实现的呢?
(defn pathname [pname]
(if (= pname "index") "public/index.html"
(str "public/" pname "/index.html")))
(defn page [pname, html]
(.mkdir (io/file "public"))
(when-not (= pname "index")
(.mkdirs (io/file (str "public/" pname))))
(spit (pathname pname) (str html)))
(defn site [page_map]
(doall
(map (fn [[pname html]]
(page (name pname) html))
page_map)))
然后就能得到这样的目录结构。
public
├── contact
│ └── index.html
└── index.html
再讨论细节就有些繁琐了,总之,现在有一个能够生成静态网站的框架了,并且一共才写了不到 100 行代码。为了方便后续开发,我把上述功能拆成了几个模块,源码可以在
我的 Forgejo 实例
上查看。
最后
Capricorn 的实现不是最优的,也不能称得上真正的静态网站生成器,但对于目前的问题规模(生成只有一两个页面的简单网站)而言,已经很好了,不需要再扩张,后续可能会加上自动生成的导航栏,用 ClojureScript 写一两个小组件获取 NeoDB 和 WakaTime 数据等等。要添加这些功能,在 Lisp 里就显得很容易,只需要用 (defn) 定义一个函数就好了。这就是代码即数据的威力啊(并非)。
我学 PHP 时,写出来的第一个程序是
一个 Typecho 主题
;学 Lua 时,写出来的第一个程序是
一个简陋的肉鸽游戏
;用 Go 语言写的第一个程序是
一个简单的 CLI 工具
。现在学了 Clojure,写的第一个程序竟然是生成固定内容的网页生成器。这么看来,我果然是退步了吧!
学 Lisp 的确有很奇妙的感受。《代码简洁之道》这本书里,作者认为函数里最好只有两三行代码,并且所有代码都要在一个抽象层次上,这需要克制、熟练和清晰的头脑。神奇的是,在 Lisp 里,这是非常自然的,因为一旦我写出了不太优雅的代码,代码的结构也会随之变得扭曲起来,比如这样:
(def pages ["index" "contact"])
(def nav
[:nav.nav [:ul
(for [p pages] [:li
[:a {:href (str "/"
(if (= p "index") "" p))} p]])]] )
在 Lisp 里面写出太长或者耦合度太高的函数,代码的结构就会变得很奇怪,带来清晰的视觉反馈;写出简单小巧的函数才是自然的。虽然不知道这是不是所有 Lisp 程序员的感受,但 Lisp 的确让我开始思考程序结构的美感。
此外,如果你是个资深的 Lisp 程序员,读到我在文章里写出的 Lisp 代码,还请不要气昏过去。
先这样吧。各位,我要去研究
Paredit
了。
-
如果要创建目录(mkdir)的话,还是要引入 Java 的 I/O 库的,spit 只提供了创建和写入文件的能力,不能处理目录。如果要读文件,还有一个对应的 slurp(吸溜)——真的是…… 好简洁明了的命名方式。 ↩︎
-
第一次觉得 <p> 标签特别可爱,像一张吐舌头的脸::p。还有 <a> 标签::a XD ↩︎