basic rendering!

This commit is contained in:
2026-02-01 00:36:36 -07:00
parent 6028aad2f2
commit fa446589d3
13 changed files with 859 additions and 19 deletions

1
.envrc
View File

@@ -1 +1,2 @@
IBM_TELEMETRY_DISABLED='true'
use flake

View File

@@ -1,6 +1,14 @@
{
"lock-version": 4,
"git-deps": [],
"git-deps": [
{
"lib": "io.github.msyds/spec-dict",
"url": "https://github.com/msyds/spec-dict.git",
"rev": "531d629b7f05f37232261cf9e8927a4b5915714f",
"git-dir": "https/github.com/msyds/spec-dict",
"hash": "sha256-5hMdPsB8OhOCtByPZS+CHXzVLq0H+OBKKnXec21xwmg="
}
],
"mvn-deps": [
{
"mvn-path": "babashka/fs/0.5.24/fs-0.5.24.jar",

View File

@@ -2,5 +2,7 @@
babashka/fs {:mvn/version "0.5.24"}
org.clojure/core.match {:mvn/version "1.1.0"}
cheshire/cheshire {:mvn/version "6.1.0"}
babashka/process {:mvn/version "0.6.25"}}
babashka/process {:mvn/version "0.6.25"}
io.github.msyds/spec-dict
{:git/sha "531d629b7f05f37232261cf9e8927a4b5915714f"}}
:paths ["src" "resources" "test"]}

View File

@@ -0,0 +1,11 @@
@charset "UTF-8";
:root
{ --ds-hoof-black: #0c0c0f
; --ds-velvet-grey: #4a4241
; --ds-untitled-1: rgb(47, 35, 28)
; --ds-untitled-2: rgb(168, 134, 109)
; --ds-buck-brown: #b57b4c
; --ds-antler-tan: #fce7ca
; --ds-fawn-spot-white: #fffffa
}

View File

@@ -0,0 +1,544 @@
@charset "UTF-8";
@import "/vendor/ibm-plex-serif/css/ibm-plex-serif-default.min.css";
@import "/vendor/ibm-plex-sans-kr/css/ibm-plex-sans-kr-default.min.css";
@import "/vendor/ibm-plex-math/css/ibm-plex-math-default.min.css";
@import "deerstar.css";
html {
font-size: 15px;
}
body {
font-family: "IBM Plex Serif", "IBM Plex Sans KR", "IBM Plex Math", Palatino, "Palatino Linotype", "Palatino LT STD", "Book Antiqua", Georgia, serif;
counter-reset: sidenote-counter;
background-color: var(--ds-antler-tan);
color: var(--ds-hoof-black);
}
/* Adds dark mode */
@media (prefers-color-scheme: dark) {
body {
background-color: var(--ds-untitled-1);
color: var(--ds-antler-tan);
}
}
math { font-family: "IBM Plex Math"; }
h1 {
font-weight: 400;
margin-top: 4rem;
margin-bottom: 1.5rem;
font-size: 3.2rem;
line-height: 1;
}
h2 {
font-style: italic;
font-weight: 400;
margin-top: 2.1rem;
margin-bottom: 1.4rem;
font-size: 2.2rem;
line-height: 1;
}
h3 {
font-style: italic;
font-weight: 400;
font-size: 1.7rem;
margin-top: 2rem;
margin-bottom: 1.4rem;
line-height: 1;
}
hr {
display: block;
height: 1px;
width: 55%;
border: 0;
border-top: 1px solid var(--ds-velvet-grey);
margin: 1em 0;
padding: 0;
}
@media (prefers-color-scheme: dark) {
hr {
border-color: var(--ds-velvet-grey);
}
}
p.subtitle {
font-style: italic;
margin-top: 1rem;
margin-bottom: 1rem;
font-size: 1.8rem;
display: block;
line-height: 1;
}
.numeral {
font-family: et-book-roman-old-style;
}
.danger {
color: red;
}
article {
padding-top: 3rem;
padding-bottom: 5rem;
padding-left: 6%;
padding-right: 0;
width: 87.5%;
margin-left: auto;
margin-right: auto;
max-width: 1400px;
}
section {
padding-top: 1rem;
padding-bottom: 1rem;
}
p,
dl,
ol,
ul {
font-size: 1.4rem;
line-height: 2rem;
}
p {
margin-top: 1.4rem;
margin-bottom: 1.4rem;
padding-right: 0;
vertical-align: baseline;
}
/* Chapter Epigraphs */
div.epigraph {
margin: 5em 0;
}
div.epigraph > blockquote {
margin-top: 3em;
margin-bottom: 3em;
}
div.epigraph > blockquote,
div.epigraph > blockquote > p {
font-style: italic;
}
div.epigraph > blockquote > footer {
font-style: normal;
}
div.epigraph > blockquote > footer > cite {
font-style: italic;
}
/* end chapter epigraphs styles */
blockquote {
font-size: 1.4rem;
}
blockquote p {
width: 55%;
margin-right: 40px;
}
blockquote footer {
width: 55%;
font-size: 1.1rem;
text-align: right;
}
section > p,
section > footer,
section > table {
width: 55%;
}
/* 50 + 5 == 55, to be the same width as paragraph */
section > dl,
section > ol,
section > ul {
width: 50%;
-webkit-padding-start: 5%;
/* Accounts for the padding from the bullet, resulting in the same width as
paragraphs. */
width: calc(55% - 40px);
}
dt:not(:first-child),
li:not(:first-child) {
margin-top: 0.25rem;
}
figure {
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
max-width: 55%;
-webkit-margin-start: 0;
-webkit-margin-end: 0;
margin: 0 0 3em 0;
}
figcaption {
float: right;
clear: right;
margin-top: 0;
margin-bottom: 0;
font-size: 1.1rem;
line-height: 1.6;
vertical-align: baseline;
position: relative;
max-width: 40%;
text-align: left
}
figure.fullwidth figcaption {
margin-right: 24%;
}
a:link,
a:visited {
color: inherit;
text-underline-offset: 0.1em;
text-decoration-thickness: 0.05em;
}
/* Sidenotes, margin notes, figures, captions */
img {
max-width: 100%;
}
.sidenote,
.margin-note {
float: right;
clear: right;
margin-right: -60%;
width: 50%;
margin-top: 0.3rem;
/* margin-bottom: 0; */
margin-bottom: 1em;
font-size: 1.1rem;
line-height: 1.3;
vertical-align: baseline;
position: relative;
}
.sidenote-number {
counter-increment: sidenote-counter;
}
li .sidenote,
li .margin-note {
/* it's so close lol */
transform: translateX(4px)
}
.sidenote-number:after,
.sidenote:before {
font-family: et-book-roman-old-style;
position: relative;
vertical-align: baseline;
}
.sidenote-number:after {
content: counter(sidenote-counter);
font-size: 1rem;
top: -0.5rem;
left: 0.1rem;
}
.sidenote:before {
content: counter(sidenote-counter) " ";
font-size: 1rem;
top: -0.5rem;
}
blockquote .sidenote,
blockquote .margin-note {
margin-right: -82%;
min-width: 59%;
text-align: left;
}
div.fullwidth,
table.fullwidth {
width: 100%;
}
div.table-wrapper {
overflow-x: auto;
font-family: "Trebuchet MS", "Gill Sans", "Gill Sans MT", sans-serif;
}
.sans {
font-family: "Gill Sans", "Gill Sans MT", Calibri, sans-serif;
letter-spacing: .03em;
}
code, pre > code {
font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 1.0rem;
line-height: 1.42;
-webkit-text-size-adjust: 100%; /* Prevent adjustments of font size after orientation changes in iOS. See https://github.com/edwardtufte/tufte-css/issues/81#issuecomment-261953409 */
}
.sans > code {
font-size: 1.2rem;
}
h1 > code,
h2 > code,
h3 > code {
font-size: 0.80em;
}
.margin-note > code,
.sidenote > code {
font-size: 1rem;
}
pre > code {
font-size: 0.9rem;
width: 52.5%;
margin-left: 2.5%;
overflow-x: auto;
display: block;
}
pre.fullwidth > code {
width: 90%;
}
.fullwidth {
max-width: 90%;
clear:both;
}
span.newthought {
font-variant: small-caps;
font-size: 1.2em;
}
input.margin-toggle {
display: none;
}
label.sidenote-number {
display: inline-block;
max-height: 2rem; /* should be less than or equal to paragraph line-height */
}
label.margin-toggle:not(.sidenote-number) {
display: none;
}
.iframe-wrapper {
position: relative;
padding-bottom: 56.25%; /* 16:9 */
padding-top: 25px;
height: 0;
}
.iframe-wrapper iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
@media (max-width: 760px) {
article {
width: 84%;
padding-left: 8%;
padding-right: 8%;
}
hr,
section > p,
section > footer,
section > table {
width: 100%;
}
pre > code {
width: 97%;
}
section > dl,
section > ol,
section > ul {
width: 90%;
}
figure {
max-width: 90%;
}
figcaption,
figure.fullwidth figcaption {
margin-right: 0%;
max-width: none;
}
blockquote {
margin-left: 1.5em;
margin-right: 0em;
}
blockquote p,
blockquote footer {
width: 100%;
}
label.margin-toggle:not(.sidenote-number) {
display: inline;
}
.sidenote,
.margin-note {
display: none;
}
.margin-toggle:checked + .sidenote,
.margin-toggle:checked + .margin-note {
display: block;
float: left;
left: 1rem;
clear: both;
width: 95%;
margin: 1rem 2.5%;
vertical-align: baseline;
position: relative;
}
label {
cursor: pointer;
}
div.table-wrapper,
table {
width: 85%;
}
img {
width: 100%;
}
}
.link.external::after
{ content: "↗"
; vertical-align: super
; font-size: 1rem
; line-height: 0
}
.center
{ align-items: "center"
; justify-content: "center"
; display: flex
; max-width: 55%
}
@media (max-width: 760px) {
.center
{ max-width: 90%
}
}
.navbar
{ padding: 0 1rem
; font-size: 1rem
; color: var(--ds-velvet-grey)
; height: 2rem
}
@media (prefers-color-scheme: dark) {
.navbar {
color: var(--ds-untitled-2)
}
}
.navbar-list
{ list-style-type: none
; padding-left: 0
; font-size: 1.2rem
; display: flex
; flex-direction: row
; flex-wrap: wrap
; column-gap: 1.5rem
}
.navbar-list li
{ display: inline
; margin-top: 0
}
#page-info
{ list-style-type: none
; padding-left: 0
; font-size: 0.8rem
; color: var(--ds-velvet-grey)
; text-align: center
}
@media (prefers-color-scheme: dark) {
#page-info {
color: var(--ds-untitled-2)
}
}
.home-link
{ text-decoration: none
}
#references ul
{ width: auto
}
figcaption {
margin-right: -60%;
width: 50%;
margin-top: 1.4rem;
line-height: 1.3;
max-width: unset;
}
figure.fullwidth {
display: inline-block;
}
figure.fullwidth figcaption {
margin-right: 0%;
float: none;
width: auto;
text-align: center;
}
@media (max-width: 760px) {
figcaption,
figure.fullwidth figcaption {
margin-right: 0%;
float: none;
width: auto;
text-align: center;
}
}
.empty-section-message
{ color: var(--ds-untitled-2);
; font-style: italic
; text-align: center
; max-width: 55%
; font-size: 1.5rem
}

View File

@@ -0,0 +1,10 @@
(ns net.deertopia.doerg.config
(:require [clojure.spec.alpha :as s]
[spec-dict.main :refer [dict]]))
(s/def ::config
(s/keys :req []))
(def default {})
(def ^:dynamic *cfg* default)

View File

@@ -0,0 +1,101 @@
(ns net.deertopia.doerg.element
(:require [babashka.process :as p]
[clojure.string :as str]
[clojure.zip :as z]
[babashka.fs :as fs]
[clojure.java.io :as io]
[cheshire.core :as json]
[clojure.spec.alpha :as s]
[spec-dict.main :refer [dict]]
[net.deertopia.doerg.config :as cfg])
(:refer-clojure :exclude [read-string]))
(defonce ^:private uniorg-script-path-atom (atom nil))
(def ^:dynamic *uniorg-timeout-after-milliseconds*
(* 10 1000))
(defn deref-with-timeout [process ms]
(let [p (promise)
process-future (future (deliver p @process))
timeout-future (future (Thread/sleep ms)
(future-cancel process-future)
(p/destroy-tree process)
(deliver p ::timed-out))]
(if (= @p ::timed-out)
(throw (ex-info (format "external command `%s' timed out after %.2fs."
(str/join " " (:cmd process))
(/ (double ms) 1000))
{:process process
:timed-out-after-milliseconds ms}))
@p)))
(defn- camel->kebab [s]
(->> (str/split s #"(?<=[a-z])(?=[A-Z])")
(map str/lower-case)
(str/join "-")))
(defn uniorg [& {:keys [in]
:or {in *in*}}]
(let [r (-> (p/process
{:in in :out :string}
"doerg-parser")
(deref-with-timeout *uniorg-timeout-after-milliseconds*))]
(if (zero? (:exit r))
(-> r :out (json/parse-string (comp keyword camel->kebab))))))
(defn read-string [s]
(with-in-str s
(uniorg :in *in*)))
(defn greater-element?
"Return truthy if `e` is a greater org-element; i.e. one that can
have children."
[e]
;; Not 100% sure if this is a valid definition. It seems that
;; Uniorg sets `:children` to an empty vector when a great element
;; lacks children.
(contains? e :children))
(defn org-element? [element]
#_
(s/valid? ::org-element element)
(and (map? element)
(contains? element :type)))
(defn of-type? [element type]
(= (:type element) type))
;;; Spec
(s/def ::org-element
(dict {:type string?}
^:opt {:contents-begin nat-int?
:contents-end nat-int?
:children (s/coll-of ::org-element
:kind seq?)}))
;;; Zipper
(defn doerg-zip [document]
(z/zipper greater-element?
:children
#(assoc %1 :children %2)
document))
(defn cata
"Catamorphism on a zipper."
[loc f]
(let [loc* (if-some [child (z/down loc)]
(loop [current child]
(let [current* (cata current f)]
(if-some [right (z/right current*)]
(recur right)
(z/up current*))))
loc)]
(z/replace loc* (f (z/node loc*)))))

View File

@@ -0,0 +1,37 @@
(ns net.deertopia.doerg.html
"Common HTML elements and utilities"
(:require [clojure.java.io :as io]))
#_
(def navbar
"Hiccup element for Deertopia.net's navbar."
[:nav.navbar
[:ol.navbar-list
[:li
[:a.home-link {:href "/"}
"🦌 deertopia.net"]]
[:li
[:a.home-link {:href "/graph"}
"graph"]]
#_
[:li
[:a.home-link {:onclick "alert('unimplemented }:(')"}
"search"]]]])
(def viewport
[:meta {:name "viewport"
:content "width=device-width, initial-scale=1.0"}])
(def charset
[:meta {:charset "utf-8"}])
(def tuftesque
#_
[:link {:rel "stylesheet"
:type "text/css"
:href "/resources/tuftesque.css"}]
[:style
(slurp (io/resource "net/deertopia/doerg/tuftesque.css"))])
(def head
(list viewport charset tuftesque))

View File

@@ -1,17 +0,0 @@
(ns net.deertopia.doerg.parse
(:require [babashka.process :as p]
[babashka.fs :as fs]
[clojure.java.io :as io]
[cheshire.core :as json])
(:refer-clojure :exclude [read-string]))
(defonce ^:private uniorg-script-path-atom (atom nil))
(defn- uniorg []
@(p/process {:in (slurp "/home/msyds/org/20260124165717-if_so_in_korean.org")
:out :string}
"doerg-parser"))
(defn read-string [s]
#_
(p/process "node" (uniorg-script-path)))

View File

@@ -0,0 +1,76 @@
(ns net.deertopia.doerg.render
(:require [net.deertopia.doerg.element :as element]
[clojure.tools.logging :as l]
[clojure.tools.logging.readable :as lr]
[net.deertopia.doerg.html :as doerg-html]
[clojure.zip :as z]))
;;; Top-level API
(defmulti org-element
"Render an Org element to Hiccup."
#(do (assert (element/org-element? %)
"Not an org-node!")
(:type %)))
(defmulti org-link
"Render an Org-mode link element to Hiccup. Dispatches on link
type/protocol."
#(do (assert (element/of-type? % "link"))
(:link-type %)))
(defmulti org-special-block
"Render an Org-mode special block to Hiccup. Dispatches on special
block type (as in #+begin_«type» … #+end_«type»)."
#(do (assert (element/of-type? % "special-block"))
(:block-type %)))
(defmulti org-keyword
"Render an Org-mode keyword."
#(do (assert (element/of-type? % "keyword"))
(:key %)))
(def ^:dynamic ^:private *document-info*)
(declare ^:private gather-footnotes renderer-error)
(defn org-element-recursive
"Recursively render an Org-mode element to Hiccup."
[e]
(let [loc (element/doerg-zip e)]
(-> loc
(element/cata
(fn [node]
(try (org-element node)
(catch Throwable e
(lr/error e "Error in renderer" {:node node})
(renderer-error e)))))
z/node)))
(defn org-document
"Recursively render an Org-mode document to Hiccup."
[doc]
(let [loc (element/doerg-zip doc)]
(binding [*document-info* {:footnotes (gather-footnotes loc)}]
(let [rendered (org-element-recursive doc)]
[:html
[:head
[:title "org document"]
doerg-html/viewport
doerg-html/charset
doerg-html/tuftesque]
[:body
[:article
rendered]]]))))
(defn- gather-footnotes [loc]
{})
(defn- renderer-error
"Render a `Throwable` to display within the document."
[e]
"aaaa an error!")

View File

@@ -0,0 +1,8 @@
(ns net.deertopia.doerg.config-test
(:require [net.deertopia.doerg.config :as sut]
[clojure.test :as t]
[clojure.spec.alpha :as s]))
(t/deftest default-config-is-config
(t/testing "default config is valid"
(t/is (s/valid? ::sut/config sut/default))))

View File

@@ -0,0 +1,54 @@
(ns net.deertopia.doerg.element-test
(:require [net.deertopia.doerg.element :as sut]
[babashka.process :as p]
[clojure.test :as t]
[clojure.zip :as z]
[clojure.java.io :as io]))
(defn sleep-vs-timeout [& {:keys [sleep timeout]}]
(sut/deref-with-timeout
(p/process "sleep" (format "%ds" sleep))
(* timeout 1000)))
;; Ideally we would test the following property:
;;
;; For natural numbers n and m, evaluating the form
;; (sut/deref-with-timeout
;; (p/process "sleep" (format "%ds" n))
;; (* m 1000))
;; will throw an exception iff n < m (probably with some margin of
;; error lol).
;;
;; But, this is not something that we want to run dozens-to-hundreds
;; of times. }:p
(t/deftest long-sleep-vs-short-timeout
(t/testing "long sleep vs. short timeout"
(t/is (thrown-with-msg?
Exception #".*timed out.*"
(sleep-vs-timeout :sleep 5 :timeout 1)))))
(t/deftest short-sleep-vs-long-timeout
(t/testing "short sleep vs. long timeout"
(t/is (instance? babashka.process.Process
(sleep-vs-timeout :sleep 1 :timeout 5)))))
(defn- first-child-of-type [parent type]
(some #(and (sut/of-type? % type) %) (:children parent)))
(t/deftest known-greater-elements
(t/testing "known greater elements satisfy `greater-element?`"
(let [s (-> "net/deertopia/doerg/element_test/greater-elements.org"
io/resource slurp)
root (sut/read-string s)
section (first-child-of-type root "section")
headline (first-child-of-type section "headline")
headline-text (first-child-of-type headline "text")
paragraph (first-child-of-type section "paragraph")
paragraph-text (first-child-of-type paragraph "text")]
(t/is (sut/greater-element? root))
(t/is (sut/greater-element? section))
(t/is (sut/greater-element? headline))
(t/is (not (sut/greater-element? headline-text)))
(t/is (sut/greater-element? paragraph))
(t/is (not (sut/greater-element? paragraph-text))))))

View File

@@ -0,0 +1,5 @@
#+title: greater elements test
* a headline/section
this should be a greater element