Lilac Developer Guide

All source code is generated by tangling this Org file (developer-guide.org). This file is the single source of truth for basically everything. Some other things like COPYRIGHT and LICENSE do not come from this file, but they are exceptions.

Tangling is done by loading this file into Emacs and then running (org-babel-tangle). This file is also part of the woven HTML documentation as developer-guide.html, which is referenced by the introductory docs at README.html. The HTML is generated by invoking (lilac-publish). The outputs of both tangling and weaving are checked into version control.

The Makefile in this repo is used as the main "driver" for both tangling and weaving. Typically, you would have a browser pointed to README.html or developer-guide.html (whichever one you are working on) and refresh it after editing the corresponding Org file. After every change to the Org file, you can run make to tangle, weave, and run unit tests.

1. Development environment (Nix shell)

This is the main development shell and brings in all of our dependencies to build all of our code. Taken from here. The Makefile is meant to be executed from within this environment.

🔗
let
  # Nixpkgs snapshot.
  sources = import ./package/nix/sources.nix;
  # The final "pkgs" attribute.
  pkgs = import sources.nixpkgs {};
in

# This is our development shell.
pkgs.mkShell ({
  buildInputs = [
    # Tangling and weaving for Literate Programming.
    pkgs.emacs29-nox

    # For evaluation of Python source code blocks.
    pkgs.python3Minimal

    # Spell checking.
    pkgs.typos

    # Update Nix dependencies in package/nix/sources.nix.
    pkgs.niv

    # Misc.
    pkgs.git
    pkgs.less
  ];
})

For Emacs, we use the -nox version to avoid GUI dependencies (because we always invoke Emacs in batch mode in the terminal without ever using it in an interactive manner).

2. Makefile

We have a top-level Makefile so that we can run some make commands on the command line. The overall idea is to tangle and weave, while also running any associated tests.

Note that we make use of the fake file tangle, so that we can write the top-level test rule as test: tangle, which reads more naturally than the equivalent test: Makefile or test: lilac.el.

🔗
all: test weave
.PHONY: all

test: tangle
    LILAC_ROOT=$(LILAC_ROOT) emacs --quick --batch --kill --load ert \
        --load lilac.el \
        --load lilac-tests.el \
        --funcall ert-run-tests-batch-and-exit
.PHONY: test

<<Makefile-weave>>
<<Makefile-tangle>>
<<Makefile-run_emacs>>
<<Makefile-lint>>
<<Makefile-update-deps>>

Weaving just depends on the main README.html and developer-guide files being generated. Before we call (lilac-publish), we have to first call (lilac-gen-css-and-exit) because otherwise the source code blocks do not get any syntax highlighting.

weave: lint README.html developer-guide.html
.PHONY: weave

README.html: README.org
    $(call run_emacs,(lilac-publish),$<)

developer-guide.html: developer-guide.org
    $(call run_emacs,(lilac-publish),$<)

syntax-highlighting.css:
    $(call run_emacs_nobatch,(lilac-gen-css-and-exit),)
.PHONY: syntax-highlighting.css

Tangling is pretty straightforward — we just need to call (org-babel-tangle) on developer-guide.org (the README.org does not contain any code we need to run to make this work). This generates a number of files, such as the Makefile and shell.nix.

The key here is to enumerate these generated files, because we need to tell the make utility that it should run the rule if developer-guide.org has a newer modification timestamp than any of the generated files. Technically speaking, because all of the tangled files are tangled together at once with (org-babel-tangle), we could just list one of them such as Makefile (instead of enumerating all of them). However we still enumerate them all here for completeness.

# tangled_output are all files that are generated by tangling developer-guide.org.
tangled_output = \
    citations-developer-guide.bib \
    lilac.css \
    lilac.el \
    lilac-tests.el \
    lilac.js \
    lilac.theme \
    .gitattributes \
    .gitignore \
    Makefile \
    shell.nix

tangle $(tangled_output) &: developer-guide.org
    # Generate the toplevel Makefile (this file) and others as described in
    # tangled_output. In a way this bootstraps the whole literate-programming
    # pipeline.
    $(call run_emacs,(lilac-tangle),developer-guide.org)
    touch tangle

The run_emacs function is used for both weaving and tangling. The main thing of interest here is that it loads the lilac.el (tangled) file before evaluating the given expression.

define run_emacs
    LILAC_ROOT=$(LILAC_ROOT) emacs $(2) --quick --batch --kill \
        --load $(LILAC_ROOT)/lilac.el --eval="$(1)"
endef

define run_emacs_nobatch
    LILAC_ROOT=$(LILAC_ROOT) emacs $(2) --quick --kill \
        --load $(LILAC_ROOT)/lilac.el --eval="$(1)"
endef

LILAC_ROOT := $(shell git rev-parse --show-toplevel)

We use niv to update the dependencies sourced by shell.nix. Niv uses two sources of truth: the niv repository itself on GitHub, and a branch of nixpkgs. The former tracks the master branch, and the latter tracks the stable channels (example: nixos-24.05 branch). Whenever we run niv update, niv will update the HEAD commit SHA of these branches.

One problem with the nixpkgs stable channel is that it will eventually become obsolete as newer stable channels get created. So we have to manually track these channels ourselves.

nixpkgs_stable_channel := nixos-24.05
update-deps: package/nix/sources.json package/nix/sources.nix
    cd package && niv update nixpkgs --branch $(nixpkgs_stable_channel)
    cd package && niv update
    touch update-deps

2.1. Linting

2.1.1. Spell checker

We use typos-cli to check for spelling errors. Below we configure it to only check the original source material — Org files.

🔗
[files]
extend-exclude = [
    "*.html",
    "*.json",
    "deps/*",
]

Here we have the Makefile rules for linting, which include this spellchecker.

lint: spellcheck
.PHONY: lint

spellcheck: README.org developer-guide.org
    typos
.PHONY: spellcheck

3. Tangling (generating the source code)

Tangling is simply the act of collecting the #+begin_src ... #+end_src blocks and arranging them into the various target (source code) files. Every source code block is given a unique name.

We simply tangle the developer-guide.org file to get all the code we need.

3.1. Standardized Noweb reference syntax

Lilac places the following requirements for naming Noweb references:

  1. the reference must start with a __NREF__ prefix
  2. the reference must then start with a letter, followed by letters, numbers, dashes, underscores, or periods, and may terminate with (...) where the "…" may be an empty string or some other argument.

These rules help Lilac detect Noweb references easily.

(defun lilac-nref-rx (match-optional-params)
  (rx-to-string
   (lilac-nref-rx-primitive match-optional-params)))

(defun lilac-nref-rx-primitive (match-optional-params)
  (if match-optional-params
   `(group
           "__NREF__"
          ;; Noweb reference must start with a letter...
          (any alpha)
          ;; ...and must be followed by
          ;; letters,numbers,dashes,underscores,periods...
          (* (or (any alnum) "-" "_" "."))
          ;; ...and may terminate with a "(...)" where the "..." may be an empty
          ;; string, or some other argument.
          (* (or "()"
                 (and "("
                      (* (not ")"))
                      ")"))))
   `(group
          "__NREF__"
          (any alpha)
          (* (or (any alnum) "-" "_" ".")))))

3.2. Remove trailing whitespace

When the __NREF__... is indented, and that reference has blank lines, those blank lines will inherit the indent in the parent block. This results in trailing whitespace in the tangled output, which is messy. Delete such lines from the tangled output.

(add-hook 'org-babel-post-tangle-hook (lambda ()
                                        (delete-trailing-whitespace)
                                        (save-buffer)))

3.3. Allow evaluation of code blocks

Some code blocks are generated by evaluating code in other blocks. This way, you can use all the power of Org mode (as well as any supported programming language) as a kind of meta-programming system.

Orgmode by default disables automatic evaluation of source code blocks, because it is a big security risk. For us, we know that we want to allow code evaluation, so we disable the evaluation confirmation check. This way, evaluation can still work even in batch mode.

(setq org-confirm-babel-evaluate nil)

The following is needed to evaluate the example for source code block evaluation in Monoblock (evaluation result's value), which evaluates Python. We don't need to add in Emacs lisp here because it's already supported by default (which we take advantage of in 4.11).

In addition to Python, we add in some additional languages that might be of use.

(org-babel-do-load-languages 'org-babel-load-languages
                             (append org-babel-load-languages
                              '((awk        . t)
                                (C          . t)
                                (calc       . t)
                                (clojure    . t)
                                (java       . t)
                                (js         . t)
                                (latex      . t)
                                (lisp       . t)
                                (lua        . t)
                                (perl       . t)
                                (python     . t)
                                (R          . t)
                                (ruby       . t)
                                (scheme     . t)
                                (sed        . t)
                                (shell      . t))))

3.4. Faster tangling

These settings are stolen from https://github.com/doomemacs/doomemacs/commit/295ab7ed3a20ba4619a142be15f5f2ef08d2adcf.

(defun lilac-tangle ()
  (interactive)
  (let ((org-startup-indented nil)
        (org-startup-folded nil)
        (vc-handled-backends nil)
        (write-file-functions nil)
        (before-save-hook nil)
        (after-save-hook nil)
        (org-mode-hook nil)
        (org-inhibit-startup t))
    (org-babel-tangle)))

4. Weaving (generating the HTML)

Weaving is conceptually simpler than tangling because there is no extra step — the output is an HTML page and that is something that we can use directly (unlike program source code, which may require additional compilation into a binary, depending on the language). We limit ourselves to HTML output because HTML support is ubiquitous; plus we don't have to worry about page breaks such as in PDF output.

Although weaving is conceptually simple, most of the code in lilac.el have to do with weaving because the default infrastructure that ships with Org mode is too rigid for our needs. For example, we make heavy use of Noweb-style [1] references, but also add in extensive HTML links to allow the reader to jump around code easily because Org does not cross-link these references by default.

Weaving currently requires the following dependencies:

Dependency Why
GNU Make to run "make"
GNU Emacs for tangling and weaving

Note that all of the above can be brought in by using the Nix package manager. This is why we provide a shell.nix file in this repo.

4.1. Emacs customizations (lilac.el)

Below is the overall structure of lilac.el. The gc-cons-threshold setting is to prevent emacs from entering garbage collection pauses, because we invoke emacs from the command line in a non-interactive manner.

4.2. Fix non-determinism

Nondeterminism is problematic because it results in a different HTML file every time we run org-babel-tangle, even if the Org files have not changed. Here we take care to set things right so that we can have reproducible, stable HTML output.

4.2.1. Do not insert current time as HTML comment

Org mode also injects an HTML comment (not visible to the user) to record the time that the HTML was generated. We disable this because it breaks deterministic output. See this link for more info.

(setq org-export-time-stamp-file nil)

4.2.2. Do not insert current Org mode version

By default Org mode appends visible metadata at the bottom of the HTML document, including the Org version used to generate the document. We suppress this information.

(setq org-html-postamble nil)

4.2.3. Do not use random numbers for the HTML id attribute

Stop randomized IDs from being generated every time. Instead count from 0 and work our way up.

See https://www.reddit.com/r/orgmode/comments/aagmfh/comment/hk6upbf.

(defun org-export-deterministic-reference (references)
  (let ((new (length references)))
     (while (rassq new references) (setq new (1+ new)))
     new))
(advice-add #'org-export-new-reference
            :override #'org-export-deterministic-reference)

4.3. Top-level publishing function (lilac-publish)

The top-level function is lilac-publish. This actually publishes to HTML twice, with two separate calls to org-html-export-to-html. The reason we publish twice is because we need to examine the HTML output twice in order to build up a database of parent/child source code block links (which is then used to link between these parent/child source code blocks).

Also note that we do some modifications to the Org buffer directly before exporting to HTML. The main reason is so that the source code blocks that are named __NREF__... get an automatic #+caption: ... text to go along with it (because for these Noweb-style blocks, the captions should always look uniform).

The full code listing for lilac-publish is below.

🔗
(defun lilac-publish ()
  (interactive)
  (setq org-html-htmlize-output-type 'css)
  (setq org-export-before-parsing-hook
   '(lilac-UID-for-all-src-blocks
     lilac-insert-noweb-source-code-block-captions
     lilac-UID-for-all-headlines))
  (setq org-export-filter-src-block-functions
   '(lilac-populate-child-HTML_ID-hash-table
     lilac-populate-org_id-human_id-hash-table))

  (org-html-export-to-html)
  (clrhash lilac-polyblock-names-totals)
  (setq org-export-filter-src-block-functions
   '(lilac-link-to-children-from-parent-body
     lilac-prettify-source-code-captions))
  (setq org-export-filter-final-output-functions
   '(lilac-replace-org_ids-with-human_ids))

  (org-html-export-to-html)
  (clrhash lilac-polyblock-names)
  (clrhash lilac-polyblock-names-totals)
  (clrhash lilac-org_id-human_id-hash-table)
  (clrhash lilac-human_id-count-hash-table)
  (clrhash lilac-human_id-org_id-hash-table)
  (lilac-replace-from-to-html
   "<h2>Table of Contents</h2>"
   "")
  (lilac-replace-from-to-html
   "mathjax@3/es5/tex-mml-chtml.js\">"
   "mathjax@4.0.0-beta.4/tex-mml-chtml.js\">")
  (lilac-replace-from-to-html
   ".csl-right-inline{margin: 0 0 0 1em;}<"
   ".csl-right-inline{margin: 0 0 0 2em;}<")
  (lilac-replace-from-to-html
   "\"csl-entry\"><a \\(id=\"[^\"]+\"\\)></a>"
   "\"csl-entry\" \\1>"
   t)
  (lilac-replace-from-to-html
   "<dt>"
   "<div class=\"lilac-description-list-entry\"><dt>"
   t)
  (lilac-replace-from-to-html
   "\"lilac-description-list-entry\"><dt><a \\(id=\"[^\"]+\"\\)></a>"
   "\"lilac-description-list-entry\" \\1><dt>"
   t)
  (lilac-replace-from-to-html
   "</dd>"
   "</dd></div>")
  (if (boundp 'lilac-html-head)
      (lilac-replace-from-to-html
       "<!-- LILAC_HTML_HEAD -->"
       lilac-html-head)))

Now let's go through lilac-publish in detail.

(defun lilac-publish ()
  (interactive)
  (setq org-html-htmlize-output-type 'css)

Do not hardcode colors into the HTML. Instead refer to CSS class names, to be stylized by an external CSS file.

  (setq org-export-before-parsing-hook
   '(lilac-UID-for-all-src-blocks
     lilac-insert-noweb-source-code-block-captions
     lilac-UID-for-all-headlines))

Here we modify the Org mode buffer, by using org-export-before-parsing-hook. This takes a list of functions that are free to modify the Org mode buffer before each Org element in the buffer gets converted into HTML.

As for the actual modifications, see:

  • lilac-UID-for-all-src-blocks (4.4.1)
  • lilac-insert-noweb-source-code-block-captions (4.4.2)
  • lilac-UID-for-all-headlines (4.4.3)

In brief, the lilac-UID-for-all-* functions make it so that the links to headlines and source code blocks are both deterministic and human-readable. The lilac-insert-noweb-source-code-block-captions function

Now we start modifying the HTML.

This is useful for adding in final tweaks to the HTML that is difficult to accomplish at the Org-mode buffer level.

Phase 1: In the first phase, we use the generated HTML data to populate the lilac-child-HTML_ID-hash-table. This data structure is used to link to child blocks from parent blocks. We also populate the lilac-org_id-human_id-hash-table which is used to convert HTML IDs to be more human-readable.

  (setq org-export-filter-src-block-functions
   '(lilac-populate-child-HTML_ID-hash-table
     lilac-populate-org_id-human_id-hash-table))

  (org-html-export-to-html)

In between these phases we have to call

  (clrhash lilac-polyblock-names-totals)

because of some internal housekeeping we have to do.

Phase 2: In this phase we perform the linking from parent blocks to child blocks (lilac-link-to-children-from-parent-body), and also convert the child source code captions to look prettier (lilac-prettify-source-code-captions).

  (setq org-export-filter-src-block-functions
   '(lilac-link-to-children-from-parent-body
     lilac-prettify-source-code-captions))
  (setq org-export-filter-final-output-functions
   '(lilac-replace-org_ids-with-human_ids))

  (org-html-export-to-html)

After publishing to HTML, we have to clear the intermediate hash tables used for the export, because we could be invoking lilac-publish multiple times from the same emacs session (such as during unit tests).

  (clrhash lilac-polyblock-names)
  (clrhash lilac-polyblock-names-totals)
  (clrhash lilac-org_id-human_id-hash-table)
  (clrhash lilac-human_id-count-hash-table)
  (clrhash lilac-human_id-org_id-hash-table)

The final bits are

  1. the deletion of the "Table of Contents" text from the autogenerated Table of Contents section, which we'll convert into a sidebar; and
  2. the cleaning up of some inline CSS styles that get injected into the HTML as part of citation styles.

4.4. Org modifications

4.4.1. Give all source code blocks a #+name: ... field (HTML ID)

Only source code blocks that have a #+name: ... field (org name field) get an HTML ID (org ID) assigned to it. The problem with polyblocks is that they are not assigned an org name field by default.

Of course, we still want all polyblock to have an HTML ID, which can then be extracted by lilac-get-src-block-HTML_ID to build up the lilac-child-HTML_ID-hash-table in 4.5.3. If we don't do this then parent source code blocks won't be able to link to the polyblock at all (or vice versa).

Monoblocks with a #+name: ... field get a unique HTML ID assigned to it in the form orgN where N is a hexadecimal number. By default Org generates a random number for N, but we use a simple counter that increments, starting from 0 (see 4.2.3).

Some source code blocks may not even be monoblocks, because a #+name: ... field may simply be missing.

What we can do is inject a #+name: ___anonymous-src-block-N line (where N is an incrementing number) into the beginning of the source code section of all source code blocks that need it. Then we can construct an HTML link to any source code block.

Note that the actual name __anonymous-src-block-N is not important, because it gets erased and replaced with an orgN ID during HTML export. At that point we make these orgN strings human-readable in 4.5.1.

(defun lilac-UID-for-all-src-blocks (_backend)
  (let* ((all-src-blocks
           <<all-src-blocks>>)
         (counter 0)
         (auto-names
           (-remove 'null
             (cl-loop for src-block in all-src-blocks collect
               (let* ((pos (org-element-property :begin src-block))
                      (parent-name-struct (lilac-get-src-block-name src-block))
                      (direct-name (org-element-property :name src-block))
                      (no-direct-name (s-blank? direct-name))
                      (prefix
                        (cond ((s-blank? (car parent-name-struct))
                               "___anonymous-src-block")
                              (t
                               (car parent-name-struct))))
                      (name-final
                       (format "#+name: %s-%x\n" prefix counter)))
                 (setq counter (1+ counter))
                 (when no-direct-name
                   (cons pos name-final)))))))
    (lilac-insert-strings-into-buffer auto-names)))

4.4.2. Automatic captions for Noweb source code blocks

For the parent/child source code blocks, we simply build these up by having blocks named #+name: __NREF__foo or #+header: :noweb-ref __NREF__foo. Each of these blocks can also reference other blocks by having a line __NREF__bar inside its body. When defining such blocks, we really don't want to define the #+caption: ... part manually because it gets tedious rather quickly. Yet we still have to have these #+caption: ... bits (for every __NREF__... block!) because that's the only way that Org's HTML exporter knows how to label these blocks.

The code in this section automatically generates #+caption: ... text for these __NREF__... blocks.

We want each #+caption: ... text to have the following items:

NSCB_NAME
name of the Noweb source code block,
NSCB_POLYBLOCK_INDICATOR
an indicator to show whether this block is broken up over multiple blocks, and
NSCB_LINKS_TO_PARENTS
a link back up to a parent block (if any) where this block is used; can contain more than 1 parent if multiple parents refer to this same child block

NSCB here means Noweb source code block. We loop through every source code block and insert a #+caption: ... text into the buffer. This modified buffer (with the three bits of information from above) is what is sent down the pipeline for final export to HTML (i.e., the buffer modification does not affect the actual buffer (*.org file)).

So assume that we already have the smart captions in a sorted association list (aka alist), where the KEY is the integer buffer position where this caption should be inserted, and the VALUE is the caption itself (a string), like this:

🔗
'((153  . "#+caption: ...")
  (384  . "#+caption: ...")
  (555  . "#+caption: ...")
  (684  . "#+caption: ...")
  (1051 . "#+caption: ..."))

We can use the KEY to go to that buffer position and insert the caption. However the insertion operation mutates the buffer. This means if we perform the insertions top-to-bottom, the subsequent KEY values will become obsolete. The trick then is to just do the insertions in reverse order (bottom-to-top), so that the remaining KEY values remain valid. This is what we do below, where smart-captions is an alist like the one just described.

(defun lilac-insert-noweb-source-code-block-captions (_backend)
  (let* ((parent-blocks
           <<parent-blocks>>)
         (child-parents-hash-table
           <<child-parents-hash-table>>)
         (all-src-blocks
           <<all-src-blocks>>)
         (smart-captions
           <<smart-captions>>))
    (lilac-insert-strings-into-buffer smart-captions)))

<<smart-source-code-block-captions-helpers>>

(We'll get to the helper functions smart-source-code-block-captions-helpers later as they obscure the big picture.)

Now we just have to construct smart-captions. The main difficulty is the construction of NSCB_LINKS_TO_PARENTS, so most of the code will be concerned about child-parent associations.

Why do we even need these source code blocks to link back to their parents? The point is to make things easier to navigate. For example, if we have

🔗
#+name: parent-block
#+begin_src bash
echo "Hello from the parent block"
<<__NREF__child-block-1>>
<<__NREF__child-block-2>>
#+end_src

...

#+name: __NREF__child-block-1
#+begin_src bash
echo "I am child 1"
#+end_src

...

#+header: :noweb-ref __NREF__child-block-2
#+begin_src bash
echo -n "I am "
#+end_src

#+header: :noweb-ref __NREF__child-block-2
#+begin_src bash
echo "child 2"
#+end_src

and we export this to HTML, ideally we would want both __NREF__child-block-1 and each of the __NREF__child-block-2 blocks to include an HTML link back up to parent-block. This would make it easier to skim the document and not get too lost (any time you are looking at any particular source code block, you would be able to just click on the link back to the parent (if there is one) to see a higher-level view).

The key idea here is to build a hash table (child-parents-hash-table) where the KEY is a child source code block and the VALUE is the parent block(s). Then in order to construct NSCB_LINKS_TO_PARENTS we just do a lookup against this hash table to find the parent(s), if any.

The first thing we need is a list of parent source code blocks. We consider a source code block a parent block if it has any Noweb references within its body.

(lilac-get-parent-blocks)

Then we construct the child-parents-hash-table. For each parent block, we get all of its children (child-names), and use this data to construct a child-parent association. Note that we use cl-pushnew instead of push to deduplicate parents (i.e., when a single parent refers to the same child more than once we do not want to link back to this same parent more than once from the child block's caption).

(lilac-mk-child-parents-hash-table parent-blocks)

Now that we have the child-parent associations, we have to look at all source code blocks and check if

  1. this source code block's name shows up at all in child-parents-hash-table, and if so
  2. add a link to the parent (NSCB_LINKS_TO_PARENTS).

Let's grab all source code blocks:

(org-element-map (org-element-parse-buffer) 'src-block 'identity)

And now we can finally construct smart-captions:

(lilac-mk-smart-captions child-parents-hash-table)

We used some helper functions up in __NREF__smart-source-code-block-captions; let's examine them now.

lilac-is-parent-block checks whether a source code block is a parent (contains noweb references to other child blocks in the form __NREF__child-name).

(defun lilac-is-parent-block (src-block)
  (let ((body (org-element-property :value src-block)))
    (lilac-get-noweb-children body)))

lilac-get-parent-blocks retrieves all source code blocks that are parents (which have __NREF__... references).

(defun lilac-get-parent-blocks ()
  (org-element-map (org-element-parse-buffer) 'src-block
    (lambda (src-block)
       (if (lilac-is-parent-block src-block) src-block))))

lilac-mk-child-parents-hash-table takes all parent source code blocks and generates a hash table where the KEY is the child block name and the VALUE is the list of parents that refer to the child. When we loop through parent-blocks below, we have to first reverse it because the function cl-pushnew grows the list by prepending to it.

(defun lilac-mk-child-parents-hash-table (parent-blocks)
  (let ((hash-table (make-hash-table :test 'equal)))
    (mapc
     (lambda (parent-block)
      (let* ((parent-name (org-element-property :name parent-block))
             (parent-body (org-element-property :value parent-block))
             (child-names (lilac-get-noweb-children parent-body)))
        (mapc (lambda (child-name)
                (let* ((parents (gethash child-name hash-table)))
                  (if parents
                    (puthash child-name
                             (cl-pushnew parent-name parents)
                             hash-table)
                    (puthash child-name (list parent-name) hash-table))))
              child-names)))
     (reverse parent-blocks))
    hash-table))

lilac-mk-smart-captions generates an alist of buffer positions (positive integer) and the literal #+caption: ... text that needs to be inserted back into the buffer.

(defun lilac-mk-smart-captions (child-parents-hash-table)
  (-remove 'null
    (cl-loop for src-block in <<all-src-blocks>> collect
      (let* ((child (lilac-get-src-block-name src-block))
             (child-name (car child))
             (NSCB_NAME (format "=%s= " child-name))
             (NSCB_POLYBLOCK_INDICATOR
               (if (lilac-get-noweb-ref-polyblock-name src-block)
                   "(polyblock)"
                 ""))
             (polyblock-counter
              (gethash child-name lilac-polyblock-names-totals 0))
             (polyblock-counter-incremented
              (puthash child-name (1+ polyblock-counter)
                       lilac-polyblock-names-totals))
             (parents (gethash child-name child-parents-hash-table))
             (parents-zipped (lilac-enumerate parents))
             (pos (org-element-property :begin src-block))
             (NSCB_LINKS_TO_PARENTS
              (mapconcat (lambda (parent-with-idx)
                           (format " [[%s][%d]]"
                                   (nth 1 parent-with-idx)
                                   (1+ (nth 0 parent-with-idx))))
                         parents-zipped " "))
             (smart-caption
              (concat
                "#+caption: "
                NSCB_NAME
                NSCB_POLYBLOCK_INDICATOR
                NSCB_LINKS_TO_PARENTS
                "\n")))
        (when parents (cons pos smart-caption))))))

lilac-insert-strings-into-buffer takes an alist of buffer positions and strings and inserts them all into the buffer.

(defun lilac-insert-strings-into-buffer (pos-strings)
  (cl-loop for pos-string in (reverse pos-strings) do
        (let ((pos (car pos-string))
              (str (cdr pos-string)))
          (goto-char pos)
          (insert str))))

lilac-get-noweb-children extracts all Noweb references in the form "__NREF__foo" from a given multiline string, returning a list of all such references. This function expects at most 1 Noweb reference per line. The return type is a list of strings.

(defun lilac-get-noweb-children (s)
  (let* ((lines (split-string s "\n"))
         (refs (-remove 'null
                 (mapcar
                  (lambda (line)
                   (if (string-match (lilac-nref-rx nil) line)
                       (match-string-no-properties 1 line)))
                  lines))))
    refs))

lilac-get-noweb-ref-polyblock-name gets the string __NREF__foo in a #+header: :noweb-ref __NREF__foo line for a source code block.

(defun lilac-get-noweb-ref-polyblock-name (source-code-block)
  (let* ((headers (org-element-property :header source-code-block))
         (noweb-ref-name
          (nth 0
           (-remove 'null
            (mapcar
             (lambda (header)
               (if (string-match ":noweb-ref \\(.+\\)" header)
                   (match-string-no-properties 1 header)))
             headers)))))
    noweb-ref-name))

Note that a child source block can have two ways of defining its name. The first is with the direct #+name: __NREF__foo style (monoblock), and the second way is with a line like #+header: :noweb-ref __NREF__foo (polyblock). Here lilac-get-src-block-name grabs the name of a (child) source code block, taking into account these two styles. For polyblock names, we mark it as such with a (polyblock) string, which is used later for the NSCB_POLYBLOCK_INDICATOR.

(defun lilac-get-src-block-name (src-block)
  (let* ((name-direct (org-element-property :name src-block))
         (name-indirect (lilac-get-noweb-ref-polyblock-name src-block)))
    (if name-indirect
        `(,name-indirect "(polyblock)")
        `(,name-direct ""))))

Next we have some helpers to enumerate through a list just like in Python. The index starts at 0 (same as Python).

(defun lilac-enumerate (lst &optional start)
  (let ((ret ()))
    (cl-loop for index from (if start start 0)
           for item in lst
           do (push (list index item) ret))
    (reverse ret)))

; See https://emacs.stackexchange.com/a/7150.
(defun lilac-matches (regexp s &optional group)
  "Get a list of all regexp matches in a string"
  (if (= (length s) 0)
      ()
      (save-match-data
        (let ((pos 0)
              (matches ()))
          (while (string-match regexp s pos)
            (push (match-string (if group group 0) s) matches)
            (setq pos (match-end 0)))
          (reverse matches)))))

4.4.3. Human-readable UIDs (Headings, aka headlines)

By default Org does a terrible job of naming HTML id fields for headings. By default it uses a randomly-generated number. In 4.2.3 we tweak this behavior to use a deterministic, incrementing number starting from 0. However while this solution gets rid of the nondeterminism, it still results in human-unfriendly id attributes because they are all numeric (e.g. org00000a1, org00000f3, etc).

For headings, we can do better because in practice they already mostly have unique contents, which should work most of the time to act as an id. In other words, we want all headings to have HTML IDs that are patterned after their contents. This way we can have IDs like some-heading-name-1 (where the trailing -1 is only used to disambiguate against another heading of the same name) instead of org00000a1 (numeric hex).

For each heading, we insert a CUSTOM_ID property. This makes Org refer to this CUSTOM_ID instead of the numeric org... link names. We append this headline property just below every headline we find in the buffer. The actual construction of the CUSTOM_ID (headline-UID in the code below) is done by lilac-get-unique-id.

(defun lilac-UID-for-all-headlines (_backend)
  (let* ((all-headlines
           (org-element-map (org-element-parse-buffer) 'headline 'identity))

         (headline-uid-hash-table (make-hash-table :test 'equal))
         (headline-UIDs
           (-remove 'null
             (cl-loop for headline in all-headlines collect
               (let* ((headline-UID
                       (lilac-get-unique-id headline headline-uid-hash-table))
                      ;; Get the position just after the headline (just
                      ;; underneath it).
                      (pos (progn
                             (goto-char (org-element-property :begin headline))
                             (re-search-forward "\n"))))
                 (cons pos (concat
                            ":PROPERTIES:\n"
                            ":CUSTOM_ID: " headline-UID "\n"
                            ":END:\n")))))))
    (lilac-insert-strings-into-buffer headline-UIDs)))

<<get-unique-id>>

lilac-get-unique-id converts a given headline to its canonical form (every non-word character converted to a dash) and performs a lookup against the hash table. If the entry exists, it looks up a entry-N value in a loop with N increasing until it sees that no such key exists (at which point we know that we have a unique ID).

(defun lilac-get-unique-id (headline hash-table)
  (let* ((name (org-element-property :raw-value headline))
         (disambiguation-number 0)
         (key (concat "h-" (lilac-normalize-string name)))
         (val (gethash key hash-table)))
    ;; Discard the key if a value already exists. This drives up the
    ;; disambiguation number.
    (while val
      (setq disambiguation-number (1+ disambiguation-number))
      (setq key (concat "h-"
                        (lilac-normalize-string
                         (format "%s-%s" name disambiguation-number))))
      (setq val (gethash key hash-table)))
    (puthash key t hash-table)
    key))

(defun lilac-normalize-string (s)
  (string-trim
    (replace-regexp-in-string "[^A-Za-z0-9]" "-" s)
    "-"
    "-"))

4.5. HTML modifications

4.5.2. Pretty source code captions

The default HTML export creates a <div> around the entire source code block. This <div> will have a <pre> tag with the source code contents, along with a preceding <label> if there was a #+caption: ... for this block. Because of automatic generation of #+caption: ... bits for all Noweb-style references in Section 4.4.2, the vast majority of source code blocks will have this <label> tag.

By default the <label> tag includes a Listing number ("Listing 1: …", "Listing 17: …", etc), because Org likes to numerically number every single source code block. We simply drop these listing numbers and instead link back to the parent block(s) that refer to this source code block (as a Noweb reference), if any.

For polyblock chains, we also have to keep track of how long each chain is. This way, each block in the chain will get a unique fraction denoting the position of that block in the overall chain. (If we don't do this, then all of the captions for all polyblocks in the same chain will look identical, which can be a bit confusing). In order to generate these fractions we have to keep around a couple hash tables for bookkeeping.

(setq lilac-polyblock-names (make-hash-table :test 'equal))
(setq lilac-polyblock-names-totals (make-hash-table :test 'equal))

First here is the overall shape of the function. If there is no caption (<label> tag in the HTML), then we return the original HTML unmodified. Otherwise we prettify this label (removing the "Listing N: …" text, including a link back to the parent, etc) along with the body text (linking references to child Noweb references), and return both inside a new lilac-pre-with-caption div.

(defun lilac-prettify-source-code-captions (src-block-html backend info)
  (when (org-export-derived-backend-p backend 'html)
    (let* (
           <<lilac-prettify-source-code-captions-let-bindings>>)
      (if (s-blank? caption)
          src-block-html
        (concat
          leading-div
            "<div class=\"lilac-pre-with-caption\">"
              caption-text
              body-with-replaced-pre
            "</div>"
          "</div>")))))

Now let's get into the bindings. The first order of business is parsing the HTML bits into separate parts.

div-caption-body is the original HTML but all on a single line. We need to work with the text without newlines because Emacs Lisp's regular expressions don't work well with newlines. That's why we call lilac-get-source-block-html-parts-without-newlines.

We need leading-div because this outermost div contains various class and other information that Org generates. We don't want to lose any of that info.

(div-caption-body (lilac-get-source-block-html-parts-without-newlines
                   src-block-html))
(leading-div (nth 0 div-caption-body))
(caption (nth 1 div-caption-body))
(body (nth 2 div-caption-body))
(body-with-newlines
 (lilac-to-multi-line body))

lilac-get-source-block-html-parts-without-newlines is defined below. It's just a list of parsed pieces of HTML.

(defun lilac-get-source-block-html-parts-without-newlines (src-block-html)
    (let* ((one-line (lilac-to-single-line src-block-html))
           (leading-div
             (let ((div-match
                    (string-match "<div [^>]+>" one-line)))
               (match-string-no-properties 0 one-line)))
           (caption
             (let* ((caption-match
                      (string-match "<label [^>]+>.*?</label>" one-line)))
               (if caption-match
                   (match-string-no-properties 0 one-line)
                   "")))
           (body (progn (string-match "<pre [^>]+>.*?</pre>" one-line)
                        (match-string-no-properties 0 one-line))))
      `(,leading-div ,caption ,body)))

Now come the bits for identifying the human-readable source code block name by looking at the caption (<label>). We extract it from the <code> tags that we expect to see inside the <label> tag.

It may very well be the case that the block will not have a name, in which case we just name it as anonymous. A source code block is anonymous if:

  1. it does not have a "#+name: ..." line, or
  2. it does not have a "#+header: :noweb-ref ..." line.
(caption-parts
  (let* ((caption-match
           (string-match "<label [^>]+>\\(.*?\\)</label>" caption)))
    (if caption-match
        (match-string-no-properties 1 caption)
        "")))
(source-block-name-match
  (string-match
    (rx-to-string
      '(and
            "<code>"
            (group (+ (not "<")))
            "</code>"))
    caption-parts))

Because the name anonymous is meaningless (there can be more than one such block), we need to disambiguate it. We do this by appending a numeric suffix to all source code blocks with the same human-readable source-block-name.

(source-block-name
  (if source-block-name-match
      (match-string-no-properties 1 caption-parts)
      "anonymous"))
(source-block-counter
 (gethash source-block-name lilac-polyblock-names 0))
(source-block-counter-incremented
 (puthash source-block-name (1+ source-block-counter)
          lilac-polyblock-names))

Here we do some additional introspection into the <pre> tag which holds the body text (the actual source code in the source code block). The pre-id is important because it gives us a unique ID (linkable ID) to the body text. We'll be using this when linking to a child block from a parent block.

Sadly, Org does not give every source code block an id=... field. Notably, polyblocks do not get an id except for the very first block in the chain. And so we inject an id for every <pre> tag ourselves. But first we have to see if the <pre> tag has an ID already with pre-id-match.

(pre-id-match
  (string-match
    (rx-to-string
      '(and
            "<pre "
            (* (not ">"))
            "id=\""
            (group (+ (not "\"")))))
    body))

Now we can create a universal pre-id for all <pre> tags, and make sure that all <pre> tags without a given id can use pre-id-universal as a fallback.

(pre-id-universal
  (if pre-id-match
      (match-string-no-properties 1 body)
    (format "%s-%s"
            source-block-name
            source-block-counter-incremented)))
(pre-tag-match
  (string-match
    (rx-to-string
      '(and
            "<pre "
            (group (* (not ">")))
            ">"))
    body))
(pre-tag-entire (match-string-no-properties 0 body))
(pre-tag-contents (match-string-no-properties 1 body))
(body-with-replaced-pre
  (if pre-id-match
      body-with-newlines
      (string-replace pre-tag-entire
                      (concat "<pre " pre-tag-contents
                              (format " id=\"%s\"" pre-id-universal) ">")
                      body-with-newlines)))

For polyblocks though, we also want to show a fraction in the form (N/TOTAL) where N is the numeric position of the polyblock (1 for the head, 2 for the second one in the chain, 3 for the third, and so on), and TOTAL is the total number of polyblocks in the chain. This way the reader can get some idea about how many pieces there are as the overall chain is explained in the prose. This (N/TOTAL) fraction is called a polyblock-indicator.

Note that we use the (polyblock) marker text from NSCB_POLYBLOCK_INDICATOR to detect whether we're dealing with a polyblock, because otherwise all of those anonymous blocks will get treated as part of a single polyblock chain.

(polyblock-chain-total
 (gethash source-block-name lilac-polyblock-names-totals 0))
(polyblock-indicator
 (if (and
      (> polyblock-chain-total 0)
      (string-match "\(polyblock\)" caption-parts))
     (format "(%s/%s) "
             source-block-counter-incremented
             polyblock-chain-total)
   ""))

Most source code blocks have a parent where this code block's contents should be inserted into. There could be more than one parent if the code is reused verbatim in multiple places.

We generate a link back to the parent (or parents), by extracting the links (href bits in the <a> (aka anchor) tags) found in the caption. These links were generated for us in Section 4.4.2; our job is to prettify them with CSS classes and such.

(parent-id-regexp
    (rx-to-string
      '(and
            " <a href=\""
            (group (+ (not "\""))))))
(parent-ids-with-idx
 (lilac-enumerate
  (lilac-matches parent-id-regexp caption-parts 1) 1))
(parent-links
  (mapconcat (lambda (parent-id-with-idx)
               (let ((parent-id (car (cdr parent-id-with-idx)))
                     (idx (car parent-id-with-idx)))
                  (format (concat
                           "<span class="
                           "\"lilac-caption-parent-link\">"
                           "<a href=\"%s\">%s</a></span>")
                    parent-id
                    (if (= idx 1)
                        (string-remove-prefix
                         "__NREF__" source-block-name)
                      idx))))
             parent-ids-with-idx ""))

Every source code block gets a self-link back to itself (shown as a link icon "🔗"). This goes at the very end of the caption on the far right (top right corner of the source code block's rectangular area).

(link-symbol
  (format (concat "<span class=\"lilac-caption-link-symbol\">"
                  "<a href=\"#%s\">&#x1f517;</a></span>")
    pre-id-universal))

Finally, we're ready to recompose the overall source code block. We make a distinction for source code blocks that have links back to a parent (or multiple parents). In all cases we make sure to remove Org's default "Listing N:" prefix.

(caption-without-listing-prefix
 (replace-regexp-in-string "<span.+?span>" "" caption))
(caption-text
 (if (> (length parent-links) 0)
     (concat
       "<div class=\"lilac-caption\">"
         parent-links
         polyblock-indicator
         link-symbol
       "</div>")
     (concat
       "<div class=\"lilac-caption\">"
         caption-without-listing-prefix
         link-symbol
       "</div>")))

4.5.4. Search and replace hardcoded things

Org's HTML export hardcodes some things. We have to do some manual surgery to set things right. First let's define a generic search-and-replace function. This function is based on this example.

(defun lilac-replace-from-to (str repl)
  (interactive "sString: \nsReplacement: ")
  (save-excursion
    (goto-char (point-min))
    (replace-string str repl)))

Here's a basic wrapper to perform the string replacement for an HTML file based on the current buffer name.

(defun lilac-replace-from-to-html (str repl &optional regex)
  (let ((html-file-name (concat
                         (file-name-sans-extension (buffer-file-name))
                         ".html")))
    (find-file html-file-name)
    (goto-char 0)
    (if regex
        (replace-regexp str repl)
        (lilac-replace-from-to str repl))
    (save-buffer)))

Now we do some basic search-and-replace commands to clean up the exported HTML file from Emacs (instead of invoking sed).

4.5.4.1. MathJax with line breaking support

Surprisingly, MathJax v3 (which Org ships with) does not support manual line breaks. However, line breaks are supported in the new v4 alpha version. So use that.

(lilac-replace-from-to-html
 "mathjax@3/es5/tex-mml-chtml.js\">"
 "mathjax@4.0.0-beta.4/tex-mml-chtml.js\">")

We have to include the trailing (and somewhat redundant) \"> so that the function does not replace the text above as well as the intended raw HTML (hardcoded) bits that we want to replace.

4.5.4.2. Bibliography (citations)

This cleans up the inline styles for citations. Again we include some additional characters in the pattern (notably the left angle bracket (<)) so that the text below itself does not get recognized and replaced.

(lilac-replace-from-to-html
 ".csl-right-inline{margin: 0 0 0 1em;}<"
 ".csl-right-inline{margin: 0 0 0 2em;}<")

We also do some replacements for the bibliography entries themselves. Namely, each entry has the following (odd) structure by default:

<div class="csl-entry"><a id="citeproc_bib_item_1"></a> ... </div>

and instead we delete the content-less link anchor and instead move the ID to the parent div, like this:

<div class="csl-entry" id="citeproc_bib_item_1"> ... </div>

This way we can target this surrounding parent div for the active HTML class attribute (instead of the empty link anchor) to style the entire entry when we click a link to go to it.

(lilac-replace-from-to-html
 "\"csl-entry\"><a \\(id=\"[^\"]+\"\\)></a>"
 "\"csl-entry\" \\1>"
 t)

And here we style these entries.

.csl-entry {
    padding: 0.5em;
    border-style: solid;
    border-width: 1px;
    border-radius: 5px;
    border-color: var(--clear);
    margin-left: 0;
    padding-left: 2em;
}

.outline-2 .csl-bib-body .active {
    <<css-active-item-headline>>
}
4.5.4.3. Glossary (description lists)

Similar to the bibliography entries, the description lists in HTML have empty link anchors in them, because of the way we insert the link anchors manually in the Org text (this is a convention we follow; see the 6 for examples). We get rid of these anchors and instead create a surrounding div around it, so that we can highlight the enclosed <dt> (description term) and <dd> (description details).

(lilac-replace-from-to-html
 "<dt>"
 "<div class=\"lilac-description-list-entry\"><dt>"
 t)
(lilac-replace-from-to-html
 "\"lilac-description-list-entry\"><dt><a \\(id=\"[^\"]+\"\\)></a>"
 "\"lilac-description-list-entry\" \\1><dt>"
 t)
(lilac-replace-from-to-html
 "</dd>"
 "</dd></div>")

And here we style it.

.lilac-description-list-entry {
    padding: 0.2em 0.5em 0.5em 0.5em;
    border-style: solid;
    border-width: 1px;
    border-radius: 5px;
    border-color: var(--clear);
}

.org-dl .active {
    <<css-active-item-headline>>
}

4.5.5. Custom HTML "head" section

If a user wants to use a custom Google web font, they have to make the HTML page pull it in in the <head> part of the page. This requires modifying the HTML. In order to facilitate this, we provide a replaceable piece of text that can be swapped out for the value that the user can provide. Specifically, we inject the line <!-- LILAC_HTML_HEAD --> into the HTML, and this can be replaced by the value of the lilac-html-head variable in Emacs Lisp (which can be provided by the user when they invoke the lilac-publish function).

(if (boundp 'lilac-html-head)
    (lilac-replace-from-to-html
     "<!-- LILAC_HTML_HEAD -->"
     lilac-html-head))

4.6. JavaScript

We use JavaScript to make the HTML document more useful. The primary concern is to make it easier to navigate around the various intra-document links.

In thelilac.theme file, we include lilac.js and also jQuery because we depend on it.

#+HTML_HEAD: <script src="https://code.jquery.com/jquery-3.6.4.min.js"></script>
#+HTML_HEAD: <script src="lilac.js"></script>

4.6.1. Self-linked headlines

Every HTML element h2 to h6 (which encode the Org mode headlines) already come with a unique ID, but they are not linked to themselves. We add the self-links here, which makes it easy for users to link to them directly when they're reading the page.

The JavaScript below is taken from here.

Linking itself makes all the headlines blue (the default color for links). This is a bit distracting, so make them black.

a.section-heading {
    color: black;
    text-decoration: none;
    display: block;
}

Lastly, show a link anchor icon when the user hovers over a headline. We have the icon present at all times; we only make it visible when we hover over the heading. This way, there is no "jumping" of any kind when the heading is about the same size as the width of the enclosing div (it can jump when displaying the icon results in a new line break).

a.section-heading::after {
    opacity: 0;
    transition: opacity 150ms;
    content: "\1f517";
    padding-left: 1rem;
}
a.section-heading:hover::after {
    opacity: 1;
    transition: opacity 150ms;
    content: "\1f517";
}

4.6.2. Table of contents sidebar

By default Org's HTML export gives us a "Table of Contents". We make several modifications to it:

  1. remove the "Table of Contents" phrase because it doesn't add much value,
  2. convert it to a sidebar on the left, and
  3. track the current headline.
4.6.2.1. Delete "Table of Contents" text
(lilac-replace-from-to-html
 "<h2>Table of Contents</h2>"
 "")
4.6.2.2. Convert "Table of Contents" to a sidebar on the left
#table-of-contents {
    position: fixed;
    top: 0;
    float: left;
    margin-left: -300px;
    width: 280px;
    font-size: 90%;
    border-right-style: solid;
    border-right-width: 1px;
    border-right-color: var(--border-light);
}
#text-table-of-contents {
    overflow-y: scroll;
    height: 100vh;
    padding-right: 20px;
    padding-left: 5px;
}
#text-table-of-contents li {
    font-family: var(--font-sans);
}
#text-table-of-contents ul li {
    font-weight: bold;
}
#text-table-of-contents ul li ul li {
    font-weight: normal;
}
#text-table-of-contents ul {
    margin: 0;
    padding: 0;
}
#text-table-of-contents > ul > li {
    padding-top: 1em;
}
#text-table-of-contents > ul > li:last-child {
    padding-bottom: 1.5em;
}
4.6.2.3. Track the current headline

We use some JavaScript to track the current headline when we scroll up or down the page, forcing it to stay in sync with the content in the main body. We initially used Bootstrap's scrollspy component, but dropped it because it was too heavy and opinionated.

function scrollIntoViewIfNeeded(target) {
    if ((target.getBoundingClientRect().bottom > window.innerHeight)
        || (target.getBoundingClientRect().top < 0)) {
        target.scrollIntoView({ behavior: "smooth",
                                block: "center",
                                inline: "center" });
    }
}

function deactivate_other_toc_items(hash) {
    $("#text-table-of-contents a").each((index, elt) => {
        if (elt.hash !== hash) {
            $(elt).removeClass("active");
        }
    })
}

function get_toc_item(hash) {
    return $(`#text-table-of-contents a[href='${hash}']`)[0];
}

$(document).ready(() => {
    $("#text-table-of-contents a").click((e) => {
        var tocItem = get_toc_item(e.target.hash);
        $(tocItem).addClass("active");
        deactivate_other_toc_items(e.target.hash);
    });

    $("*[id^='outline-container-h-']").each((index, elt) => {
        var hash = elt.getAttribute("id")
        hash = hash.replace("outline-container-", "#")
        var tocItem = get_toc_item(hash);
        elt.addEventListener("mouseover", () => {
            $(tocItem).addClass("active");
            deactivate_other_toc_items(hash);
        });
        elt.addEventListener("mouseover", (e) => {
            // If we don't call stopPropagation(), we end up scrolling *all*
            // elements in the stack, which means we will try to scroll e.g.,
            // Section 5 and Section 5.1.2.3 (when we only want the latter).
            e.stopPropagation();
            // Unfortunately, scrollIntoViewIfNeeded is not supported on
            // Firefox. Otherwise we could do
            //
            //    elems[0].scrollIntoViewIfNeeded({ block: "center" });
            //
            // instead. So here we call the custom function that does what we
            // want.  See https://stackoverflow.com/a/37829643/437583.
            scrollIntoViewIfNeeded(tocItem);
        });
    });
});

We add some styling for the "active" headline. The main point is to add a green background and border around it. For the border, we have to make the non-active headlines have a white (invisible) border around it because otherwise the active border makes the item jump a little bit when it's applied.

We have to colorize the foreground color of the link because otherwise it becomes the color of all other links as per 4.10.8.

#table-of-contents a {
    display: block;
    transition: all 300ms ease-in-out;
    padding: 5px;
    border-radius: 4px;
    border-style: solid;
    border-width: 1px;
    border-color: var(--clear);
    color: var(--fg-heading);
}
#table-of-contents .active {
    color: var(--fg-heading);
    <<css-active-item>>
}
#table-of-contents a:hover {
    text-decoration: none;
}

4.6.3. Highlight and scroll to just-clicked-on item

When we click on any link (typically a code block but it can also be a headline or some other intra-document link destination), the browser shifts the page there. But sometimes we are already near the link destination so the page doesn't move. Other times we get moved all the way to the top or the bottom of the page, so by the time the browser finishes moving there, the user can be confused as to know which destination the browser wanted to go to. This can be somewhat disorienting.

The solution is to highlight the just-clicked-on link's destination element. Every time we click on anything, we add a class to the destination element. Then from CSS we can make this visually compelling. This way we give the user a visual cue when clicking on links that navigate to a destination within the same document.

We adapt the code in 4.6.2.3 to do what we need here. The main difference is that while the code there is concerned with adding and removing the active class from the #text-table-of-contents div, here we want to do the same but for the outline-2 div which contains the outline text (There can be multiple outline-2 divs, but this is immaterial.)

We also use the scrollIntoViewIfNeeded function to scroll the item into view, but only if we need to. This way we minimize the need to scroll, resulting in a much less "jumpy" experience.

Now we just need to style the active element.

background-color: var(--bg-toc-item);
border-color: var(--fg-toc-item);

For headlines, we draw a border around it.

border-style: solid;
border-width: 1px;
border-radius: 5px;
<<css-active-item>>

For span nodes, we also draw a border around it. Spans can be the destination when we link to a line inside a source code block.

display: inline-block;
width: 100%;
border-style: solid;
border-width: 1px;
border-radius: 5px;
<<css-active-item>>

Different destination elements need different selectors. We cover most of them here.

.outline-2 h2.active {
    <<css-active-item-headline>>
}
.outline-2 h3.active {
    <<css-active-item-headline>>
}
.outline-2 h4.active {
    <<css-active-item-headline>>
}
.outline-2 h5.active {
    <<css-active-item-headline>>
}
.outline-2 h6.active {
    <<css-active-item-headline>>
}
.outline-2 pre.active {
    <<css-active-item>>
}
.outline-2 span.active {
    <<css-active-item-span>>
}

4.6.4. Scroll to history item

Normally, browsers only treat links across URLs as new points in history; this means that for links within the page, their history is not saved. We make sure to save it explicitly with history.pushState() though in HISTORY_PUSHSTATE. So then every time we want to go back in history (by pressing the "back" arrow button in the browser), we just need to scroll to it. We already scroll to the element we click on when we push on a new history item into the stack, so there's no need to keep it symmetric here.

We have to activate the restored history item (to re-highlight it with the active class), so we do that also.

Lastly, setting the scrollRestoration property is critical because otherwise the browser will want to restore the custom scroll position (instead of going to the history item location we've saved).

$(document).ready(() => {
    history.scrollRestoration = "manual";
    window.addEventListener("popstate", function (e) {
        if (e.state === null) {
           return;
        }
        var hash = e.state.hash;
        e.preventDefault();
        scrollIntoViewIfNeeded($(hash)[0]);
        $(hash).addClass("active");
        deactivate_other_non_toc_items(hash);
    });
});

4.7. Autogenerate CSS for syntax highlighting of source code blocks

Generate syntax-highlighting.css and quit emacs. This function is designed to be run from the command line on a fresh emacs instance (dedicated OS process). Unfortunately, it can only be run in interactive mode (without the --batch flag to emacs).

(defun lilac-gen-css-and-exit ()
  (load-theme 'tango t)
  (font-lock-flush)
  (font-lock-fontify-buffer)
  (org-html-htmlize-generate-css)
  (with-current-buffer "*html*"
    (write-file "syntax-highlighting.css"))
  (kill-emacs))

If we use the workaround from here, we can generate a CSS file with colors from batch mode. However, the hackiness is not worth it.

4.8. Misc settings

4.8.1. Use HTML5 export, not XML (to un-break MathJax)

By default on Org 9.6, MathJax settings (JavaScript snippet) gets wrapped in a CDATA tag, and we run into the same problem described on this email that has gone unanswered: https://www.mail-archive.com/emacs-orgmode@gnu.org/msg140821.html. It appears that this is because the document is exported as XML, not HTML. Setting the document type to html5, as below, appears to make the CDATA tag magically disappear.

(setq org-html-doctype "html5")

4.8.2. Set citeproc styles folder

For some reason we cannot specify citeproc styles based on a relative path in our Org file. The solution is to set the org-cite-csl-styles-dir variable. See this post.

(setq org-cite-csl-styles-dir
      (concat (getenv "LILAC_ROOT") "/deps/styles/"))

4.8.3. Code references

4.8.3.1. Define CodeHighlightOn and CodeHighlightOff

If we don't do this, we get an error because the "coderef" links (the links inside code blocks, for example ;ref:NSCB_NAME) will still try to run the CodeHighlightOn and CodeHighlightOff JavaScript functions. Turning this setting on here injects the definitions of these functions into the HTML.

(setq org-html-head-include-scripts t)
4.8.3.2. Do not highlight coderefs
.code-highlighted {
    background-color: inherit;
}

4.8.4. Preserve leading whitespace characters on export

(setq org-src-preserve-indentation t)

4.8.5. Set tab width to 4 spaces

This matters for languages that require tabs, such as Makefiles.

(setq-default tab-width 4)

4.8.6. Disable backups

Disable backup files for lilac.el (that look like lilac.el~) when we invoke Emacs from the Makefile.

(setq make-backup-files nil)

4.8.7. Profiling

We don't use this very often, but it's mainly for determining cost centers for weaving and tangling.

(defun lilac-publish-profile ()
  (interactive)
  (profiler-start 'cpu)
  (lilac-publish)
  (profiler-stop)
  (profiler-report)
  (profiler-report-write-profile "emacs-profile-weave.txt") t)

(defun lilac-tangle-profile ()
  (interactive)
  (profiler-start 'cpu)
  (org-babel-tangle)
  (profiler-stop)
  (profiler-report)
  (profiler-report-write-profile "emacs-profile-tangle.txt") t)

4.9. Imports

If we want syntax-highlighting to work for a code block, that code block's major mode (language) must be loaded in here.

;; Built-in packages (distributed with Emacs).
(require 'elisp-mode)

(defun lilac-load (p)
  (add-to-list 'load-path
    (concat (getenv "LILAC_ROOT") p)))

;; Third-party packages (checked in as Git submodules)
(lilac-load "/deps/elisp/s.el")
(require 's)
(lilac-load "/deps/elisp/compat.el")
(require 'compat)
(lilac-load "/deps/elisp/dash.el")
(require 'dash)
(lilac-load "/deps/elisp/dr-qubit.org")
(lilac-load "/deps/elisp/f.el")
(lilac-load "/deps/elisp/parsebib")
(lilac-load "/deps/elisp/citeproc-el")
(require 'citeproc)
(require 'oc-csl)
(lilac-load "/deps/elisp/emacs-htmlize")
(require 'htmlize)
(lilac-load "/deps/elisp/magit/lisp")
(require 'magit-section)
(lilac-load "/deps/elisp/nix-mode")
(require 'nix-mode)
(lilac-load "/deps/elisp/elquery")
(require 'elquery)

4.10. Additional (hand-tweaked) CSS

4.10.1. Colors and fonts

We define colors and fonts in one place and reuse them throughout the rest of the CSS, with the var() function.

This would be an excellent candidate to copy/paste/tweak into your own lilac-override.css file to tweak any of the colors or fonts used by Lilac.

:root {
    --bg: #fff;
    --fg: #000;
    --bg-code: #f7f7f7;
    --border-light: #ccc;
    --fg-link: #0000ff;
    --fg-nref-link: #21618c;
    --bg-nref-link: #d8f6ff;
    --fg-nref-link-hover: #7d3c98;
    --bg-nref-link-hover: #f8e9ff;
    --fg-toc-item: green;
    --bg-toc-item: #f1ffef;
    --fg-heading: black;
    --clear: rgba(255, 255, 255, 0);
    --font-serif: "Source Serif 4", serif;
    --font-sans: "Source Sans 3", sans;
    --font-mono: "Source Code Pro", monospace;
}

4.10.2. General body text

body {
    font-size: 1.25em;
    width: 800px;
    margin-left: 310px;
    overflow-x: hidden;
    background-color: var(--bg);
    color: var(--fg);
}

body, p, li, legend {
    font-family: var(--font-serif);
}

h2, h3, h4, h5, h6 {
    margin: 0.5em 0 0 -4px;
    padding: 0 4px;
    display: inline-block;
    border-style: solid;
    border-width: 1px;
    border-color: var(--clear);
}

h1, h2, h3, h4, h5, h6, dt {
    font-family: var(--font-sans);
}

h3, h4, h5, h6 {
    font-weight: normal;
}

p, li {
    line-height: 1.2em;
}

p, ol, ul {
    margin-top: 0;
    margin-bottom: 0;
    padding-top: 1em;
}

li {
    margin-bottom: 0;
}

4.10.3. Headline font sizes

h1 {
    font-size: 3em;
}

h2 {
    font-size: 2.4em;
}

h3 {
    font-size: 2em;
}

h4 {
    font-size: 1.6em;
}

h5 {
    font-size: 1.2em;
}

h6 {
    font-size: 1em;
}
4.10.3.1. Title font
h1.title {
    font-family: "Source Serif 4", serif;
    font-size: 60pt;
    margin-top: 0.4em;
    margin-bottom: 0;
}

4.10.4. Tables

table {
    margin: 1em auto 0em auto;
    border-spacing: 0;
    border-radius: 4px;
    border: 1px solid var(--border-light);
    border-collapse: unset;
    overflow: hidden;
}

th, td {
    padding: 3px 6px;
    border-right: 1px solid var(--border-light);
    border-bottom: 1px solid var(--border-light);
}

th:last-child, td:last-child  {
    border-right: none;
}

tr:last-child td {
    border-bottom: none;
}

thead {
    background-color: var(--bg-code);
}

4.10.5. Description lists

dl {
    margin: 0;
    padding-top: 1em;
}

4.10.6. Images

img {
    display: block;
    margin: 0 auto;
}

4.10.7. Monospaced

code {
    background-color: var(--bg-code);
    padding-top: 2px;
    padding-left: 5px;
    padding-right: 5px;
    white-space: nowrap;
    border-radius: 4px;
    border-style: solid;
    border-width: 1px;
    border-color: var(--border-light);
}

pre {
    font-family: var(--font-mono);
    font-size: 0.8em;
    border-radius: 5px;
    margin: 1em 0 0 0;
    background-color: var(--bg-code);
    border-color: var(--border-light);
    min-width: fit-content;
}

Also make the background color of the programming language hover text the same as what we have elsewhere. This hover text comes with Org mode's HTML export of source code blocks.

pre.src::before {
    background-color: var(--bg-code);
}

4.10.9. Source code block body

.org-src-container {
    padding-top: 1em;
}

.org-src-container pre {
    margin: 0;
    border-width: 0;
    scroll-margin-top: 100px;
    border-style: solid;
    border-width: 1px;
    border-color: var(--border-light);
    border-radius: 5px;
}

/* Source code block body. */
.org-src-container pre.src {
    background-color: var(--bg-code);
}

4.10.10. Source code block captions

.lilac-caption {
    font-family: var(--font-mono);
    text-align: right;
    border-top-left-radius: 5px;
    border-top-right-radius: 5px;
    padding-top: 2px;
    padding-bottom: 2px;
}

.lilac-caption label {
    margin-right: 10px;
    padding-left: 10px;
    padding-right: 10px;
    padding-bottom: 10px;
    border-radius: 5px;
    border-style: solid;
    border-bottom-style: none;
    border-bottom-left-radius: 0;
    border-bottom-right-radius: 0;
    border-width: 1px;
    background-color: var(--bg-code);
    border-color: var(--border-light);
}

.lilac-caption-source-code-block-name {
    color: #444444;
    font-weight: bold;
    margin-right: 5px;
}

.lilac-caption-parent-link {
    margin-top: 5px;
    margin-right: 5px;
    padding-left: 5px;
    padding-right: 5px;
    font-weight: bold;
}
.lilac-caption-parent-link a {
    padding-left: 10px;
    padding-right: 10px;
    padding-bottom: 10px;
    color: var(--fg-nref-link);
    background-color: var(--bg-nref-link);
    border-radius: 5px;
    border-style: solid;
    border-width: 1px;
    border-bottom-style: none;
    border-bottom-left-radius: 0;
    border-bottom-right-radius: 0;
    transition: all 150ms ease-in-out;
}
.lilac-caption-parent-link a:hover {
    color: var(--fg-nref-link-hover);
    background-color: var(--bg-nref-link-hover);
    text-decoration: none;
}

.lilac-caption-link-symbol a {
    margin-right: 5px;
}
.lilac-caption-link-symbol a:hover {
    text-decoration: none;
}

.lilac-caption-listing-number {
    margin-right: 5px;
}

4.10.12. Sidenotes

Sidenotes are small blurbs of text that are displayed "out-of-band", on the right margin. This right margin is good for presenting smaller ideas that shouldn't necessarily sit in the main body text.

The CSS below is drawn primarily from here, with some modifications.

.sidenote {
    font-size: 80%;
    margin-top: 1em;
    padding: 1em 0 1em 1em;
    border-left: solid;
    border-right: none;
    border-width: 1px;
    border-color: var(--border-light);
}

.sidenote p {
    padding-top: 0;
    margin-top: 1em;
}

.sidenote p:first-child {
    margin-top: 0;
}

@media (max-width: 1425px) {
    .sidenote {
        margin-left: 1em;
    }
}

@media (min-width: 1425px) {
    .sidenote {
        margin-left: 0;

        width: 280px;
        float: right;
        margin-right: -315px;
    }
}

4.10.13. Printer-friendly styling

When printing, we don't display the sidebar because it unnecessarily narrows the width of the main text area.

@media print {
    #table-of-contents {
        display: none !important;
    }
    body {
        margin-left: 0;
    }
}

4.10.14. Adjustments for mobile (touch) screens

@media (any-pointer: coarse) {
    #table-of-contents {
        display: none !important;
    }
    h1.title {
        font-size: 40pt;
    }
    body {
        margin-left: 1em;
        font-size: 1em;
        width: auto;
    }
    .lilac-caption {
        font-size: 0.8em;
    }
    pre {
        font-size: 0.7em;
        min-width: auto;
    }
}

See https://stackoverflow.com/a/74215894 for the any-pointer: coarse tip.

4.11. Create lilac.theme file

Allow HTML exports of Org files (including this one) to pull in CSS and JavaScript that we've defined for Lilac by referring to a single theme file. The inspiration for this setup comes from https://gitlab.com/OlMon/org-themes.

For the default fonts, we break up the definition over multiple lines here using Emacs Lisp for readability.

(concat
 "#+HTML_HEAD: <link rel=\"stylesheet\" href="
   "\"https://fonts.googleapis.com/css2"
     "?family=Source+Serif+4:ital,opsz,wght@0,8..60,200..900;1,8..60,200..900"
     "&family=Source+Sans+3:ital,wght@0,200..900;1,200..900"
     "&family=Source+Code+Pro:ital,wght@0,200..900;1,200..900"
 "\">")

Now we get the result of evaluating the above with __NREF__fonts-to-load() (note the trailing parentheses (), which evaluates the referenced code block before injecting its evaluated value).

Also note that we pull in both the lilac.css file which we tangle in 4.10, but this can be expanded by customizing the value of lilac-html-head, per 4.5.5. For example, you could make this variable link to a separate lilac-override.css file to override any of the values we have hardcoded in lilac.css.

🔗
#+HTML_HEAD: <link rel="preconnect" href="https://fonts.googleapis.com" />
#+HTML_HEAD: <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<<fonts-to-load()>>

<<lilac-theme-js>>

# Include additional CSS styles.
#+HTML_HEAD: <link rel="stylesheet" type="text/css" href="syntax-highlighting.css"/>
#+HTML_HEAD: <link rel="stylesheet" type="text/css" href="lilac.css" />

#+HTML_HEAD: <!-- LILAC_HTML_HEAD -->

4.12. Ignore woven HTML from git diff

Typically we only need to look at the rendered HTML output in a web browser as the raw HTML diff output is extremely difficult to parse as a human. So by default we ask Git to exclude it from git diff by treating them as binary data.

🔗
* -diff
**/*.org diff
**/.gitattributes diff
**/.gitmodules diff
**/.gitignore diff
package/nix/sources.json diff
COPYRIGHT diff
LICENSE diff

In order to still show the HTML textual diff, we can run git diff --text.

4.12.1. git add -p

Note that the above setting to treat HTML files as binary data prevents them from being considered for git add -p. In order to add them, use git add -u instead.

4.13. gitignore

🔗
**/*.auctex-auto
tangle
update-deps
weave

5. Tests

We use ERT, the Emacs Lisp Regression Testing tool for our unit tests. Pure functions that take all of their inputs explicitly ("dependency-injected") are easy to test because we just provide the various inputs and expect the function to produce certain outputs. For functions that operate on an Emacs buffer, we use with-temp-buffer to create a temporary buffer first before invoking the functions under test.

Some functions we test expect Org mode to be active (so that certain Org mode functions are available), so we turn it on here by calling (org-mode).

🔗
(require 'ert)
(require 'lilac)

(org-mode)

<<t-helpers>>
<<t-smart-caption-generation>>
<<t-link-to-child-block-from-parent-block>>

(provide 'lilac-tests)

5.1. Test helpers

5.1.1. Setup and tear-down fixture for HTML tests

The Emacs manual for ERT defines fixtures as environments which provide setup and tear-down.

When testing HTML output (behavior of (lilac-publish)), it's useful to create a temporary Org file and to generate the HTML output (as part of "setup"). Then we'd run the tests, and finally delete the temporary files (as part of "tear-down").

We use (lilac-publish-fixture) to do the aforementioned setup and tear-down for us. In between setup and tear-down, we execute the test function with a funcall.

(defun lilac-publish-fixture (fname-prefix content test)
  (let* ((fname-org (make-temp-file fname-prefix nil ".org"))
         (fname-html (concat (string-remove-suffix "org" fname-org) "html")))
    (unwind-protect
        (progn
          (with-temp-file fname-org
            (org-mode)
            (insert content))
          (find-file fname-org)
          (lilac-publish)
          (funcall test (elquery-read-file fname-html)))
      (shell-command-to-string
       (concat
        "rm -f " (mapconcat 'identity `(,fname-org ,fname-html) " "))))))

5.2. Smart source code block caption helpers

lilac-get-noweb-children should return a list of Noweb references (child block names) found in a source code block.

(ert-deftest t-lilac-get-noweb-children ()
  (let ((body
         (concat
          "#+name: foo\n"
          "#+caption: foo\n"
          "#+begin_src emacs-lisp\n"
          "; foo\n"
          "#+end_src\n")))
    (should (equal (lilac-get-noweb-children body)
                   ())))
  (let ((body
         (concat
          "#+name: parent\n"
          "#+caption: parent\n"
          "#+begin_src emacs-lisp\n"
          "; foo\n"
          "__NREF__one" "\n"
          "; bar\n"
          "__NREF__two" "\n"
          "#+end_src\n")))
    (should (equal (lilac-get-noweb-children body)
                   '("__NREF__one" "__NREF__two")))))

lilac-is-parent-block depends on lilac-get-noweb-children. We tested the latter already above, but we test the former anyway for completeness.

(ert-deftest t-lilac-is-parent-block ()
  (with-temp-buffer
    (insert "#+name: parent\n")
    (insert "#+caption: parent\n")
    (insert "#+begin_src emacs-lisp\n")
    (insert "; foo\n")
    (insert (concat "__NREF__child" "\n"))
    (insert "#+end_src\n")
    (goto-char (point-min))
    (let ((src-block (org-element-at-point)))
      (should-not (equal nil (lilac-is-parent-block src-block))))))

Here we test whether we can automatically insert captions for child blocks.

(ert-deftest t-lilac-insert-noweb-source-code-block-captions ()
  (with-temp-buffer
    (insert "#+name: parent\n")
    (insert "#+caption: parent\n")
    (insert "#+begin_src emacs-lisp\n")
    (insert "; foo\n")
    (insert (concat "__NREF__child1" "\n"))
    (insert (concat "__NREF__child2" "\n"))
    (insert "#+end_src\n")
    (insert "\n")
    (insert (concat "#+name: " "__NREF__child1" "\n"))
    (insert "#+begin_src emacs-lisp\n")
    (insert "; bar\n")
    (insert "#+end_src\n")
    (insert "\n")
    (insert (concat "#+name: " "__NREF__child2" "\n"))
    (insert "#+begin_src emacs-lisp\n")
    (insert "; baz\n")
    (insert "#+end_src\n")
    (lilac-insert-noweb-source-code-block-captions nil)
    (goto-char (point-min))
    (should (search-forward
             (concat "#+caption: =" "__NREF__child1" "=  [[parent][1]]")
             nil t))
    (should (search-forward
             (concat "#+caption: =" "__NREF__child2" "=  [[parent][1]]")
             nil t)))
  (with-temp-buffer
    (insert "#+name: parent1\n")
    (insert "#+caption: parent1\n")
    (insert "#+begin_src emacs-lisp\n")
    (insert "; foo\n")
    (insert (concat "__NREF__child1" "\n"))
    (insert (concat "__NREF__child2" "\n"))
    (insert "#+end_src\n")
    (insert "\n")
    (insert (concat "#+name: " "__NREF__child1" "\n"))
    (insert "#+begin_src emacs-lisp\n")
    (insert "; bar\n")
    (insert "#+end_src\n")
    (insert "\n")
    (insert (concat "#+name: " "__NREF__child2" "\n"))
    (insert "#+begin_src emacs-lisp\n")
    (insert "; baz\n")
    (insert "#+end_src\n")
    (insert "\n")
    (insert (concat "#+name: parent2\n"))
    (insert "#+caption: parent2\n")
    (insert "#+begin_src emacs-lisp\n")
    (insert "; foo\n")
    (insert (concat "__NREF__child1" "\n"))
    (insert "#+end_src\n")
    (lilac-insert-noweb-source-code-block-captions nil)
    (goto-char (point-min))
    (should (search-forward
             (concat "#+caption: ="
                     "__NREF__child1"
                     "=  [[parent1][1]]  [[parent2][2]]")
             nil t))
    (should (search-forward
             (concat "#+caption: =" "__NREF__child2" "=  [[parent1][1]]")
             nil t))))

Here we test retrieval of parent source code blocks.

(ert-deftest t-lilac-get-parent-blocks ()
  (with-temp-buffer
    (insert "#+name: foo\n")
    (insert "#+caption: foo\n")
    (insert "#+begin_src emacs-lisp\n")
    (insert "; foo\n")
    (insert "#+end_src\n")
    (insert "\n")
    (insert "#+name: bar\n")
    (insert "#+caption: bar\n")
    (insert "#+begin_src emacs-lisp\n")
    (insert "; bar\n")
    (insert "#+end_src\n")
    (should-not (lilac-get-parent-blocks)))
  (with-temp-buffer
    (insert "#+name: parent1\n")
    (insert "#+caption: parent1\n")
    (insert "#+begin_src emacs-lisp\n")
    (insert "; foo\n")
    (insert (concat "__NREF__child1" "\n"))
    (insert "#+end_src\n")
    (insert "\n")
    (insert "#+name: parent2\n")
    (insert "#+caption: parent2\n")
    (insert "#+begin_src emacs-lisp\n")
    (insert "; bar\n")
    (insert (concat "__NREF__child2" "\n"))
    (insert "#+end_src\n")
    (should (lilac-get-parent-blocks))))

Here we test generating the child-to-parents hash table.

(ert-deftest t-lilac-mk-child-parents-hash-table ()
  (with-temp-buffer
    (insert "#+name: foo\n")
    (insert "#+caption: foo\n")
    (insert "#+begin_src emacs-lisp\n")
    (insert "; foo\n")
    (insert "#+end_src\n")
    (insert "\n")
    (insert "#+name: bar\n")
    (insert "#+caption: bar\n")
    (insert "#+begin_src emacs-lisp\n")
    (insert "; bar\n")
    (insert "#+end_src\n")
    (let* ((parent-blocks (lilac-get-parent-blocks))
           (child-parents-hash-table
             (lilac-mk-child-parents-hash-table parent-blocks)))
      (should (equal (hash-table-count child-parents-hash-table) 0))))
  (with-temp-buffer
    (insert "#+name: parent1\n")
    (insert "#+caption: parent1\n")
    (insert "#+begin_src emacs-lisp\n")
    (insert "; foo\n")
    (insert (concat "__NREF__child1" "\n"))
    (insert "#+end_src\n")
    (insert "\n")
    (insert "#+name: parent2\n")
    (insert "#+caption: parent2\n")
    (insert "#+begin_src emacs-lisp\n")
    (insert "; bar\n")
    (insert (concat "__NREF__child2" "\n"))
    (insert "#+end_src\n")
    (let* ((parent-blocks (lilac-get-parent-blocks))
           (child-parents-hash-table
             (lilac-mk-child-parents-hash-table parent-blocks)))
      (should (equal (hash-table-count child-parents-hash-table) 2)))))

Here we test the construction of smart captions.

(ert-deftest t-lilac-mk-smart-captions ()
  (with-temp-buffer
    (insert "#+name: parent1\n")
    (insert "#+caption: parent1\n")
    (insert "#+begin_src emacs-lisp\n")
    (insert "; foo\n")
    (insert (concat "__NREF__child1" "\n"))
    (insert "#+end_src\n")
    (insert "\n")
    (insert "#+name: parent2\n")
    (insert "#+caption: parent2\n")
    (insert "#+begin_src emacs-lisp\n")
    (insert "; bar\n")
    (insert (concat "__NREF__child2" "\n"))
    (insert "#+end_src\n")
    (let* ((parent-blocks (lilac-get-parent-blocks))
           (child-parents-hash-table
             (lilac-mk-child-parents-hash-table parent-blocks))
           (smart-captions (lilac-mk-smart-captions
                            child-parents-hash-table)))
      (should (equal smart-captions nil))))
  (with-temp-buffer
    (insert "#+name: parent1\n")
    (insert "#+caption: parent1\n")
    (insert "#+begin_src emacs-lisp\n")
    (insert "; foo\n")
    (insert (concat "__NREF__child1" "\n"))
    (insert "#+end_src\n")
    (insert "\n")
    (insert "#+name: parent2\n")
    (insert "#+caption: parent2\n")
    (insert "#+begin_src emacs-lisp\n")
    (insert "; bar\n")
    (insert (concat "__NREF__child1" "\n"))
    (insert "#+end_src\n")
    (insert "\n")
    (insert (concat "#+name: " "__NREF__child1" "\n"))
    (insert "#+begin_src emacs-lisp\n")
    (insert "; child1\n")
    (insert "#+end_src\n")
    (let* ((parent-blocks (lilac-get-parent-blocks))
           (child-parents-hash-table
             (lilac-mk-child-parents-hash-table parent-blocks))
           (smart-captions (lilac-mk-smart-captions
                            child-parents-hash-table)))
      (should (equal smart-captions
       `((181 . ,(concat "#+caption: ="
                         "__NREF__child1"
                         "=  [[parent1][1]]  [[parent2][2]]\n")))))))
  (with-temp-buffer
    (insert "#+name: parent1\n")
    (insert "#+caption: parent1\n")
    (insert "#+begin_src emacs-lisp\n")
    (insert "; foo\n")
    (insert (concat "__NREF__child1" "\n"))
    (insert "#+end_src\n")
    (insert "\n")
    (insert "#+name: parent2\n")
    (insert "#+caption: parent2\n")
    (insert "#+begin_src emacs-lisp\n")
    (insert "; bar\n")
    (insert (concat "__NREF__child2" "\n"))
    (insert "#+end_src\n")
    (insert "\n")
    (insert (concat "#+name: " "__NREF__child1" "\n"))
    (insert "#+begin_src emacs-lisp\n")
    (insert "; child1\n")
    (insert "#+end_src\n")
    (insert "\n")
    (insert (concat "#+name: " "__NREF__child2" "\n"))
    (insert "#+begin_src emacs-lisp\n")
    (insert "; child2\n")
    (insert "#+end_src\n")
    (let* ((parent-blocks (lilac-get-parent-blocks))
           (child-parents-hash-table
             (lilac-mk-child-parents-hash-table parent-blocks))
           (smart-captions (lilac-mk-smart-captions
                            child-parents-hash-table)))
      (should (equal smart-captions
       `((181 . ,(concat "#+caption: ="
                         "__NREF__child1"
                         "=  [[parent1][1]]\n"))
         (247 . ,(concat "#+caption: ="
                         "__NREF__child2"
                         "=  [[parent2][1]]\n")))))))
  (with-temp-buffer
    (insert "#+name: parent1\n")
    (insert "#+caption: parent1\n")
    (insert "#+begin_src emacs-lisp\n")
    (insert "; foo\n")
    (insert (concat "__NREF__child1" "\n"))
    (insert "#+end_src\n")
    (insert "\n")
    (insert (concat "#+name: " "__NREF__child1" "\n"))
    (insert "#+begin_src emacs-lisp\n")
    (insert "; child1\n")
    (insert (concat "__NREF__child2" "\n"))
    (insert "#+end_src\n")
    (insert "\n")
    (insert (concat "#+name: " "__NREF__child2" "\n"))
    (insert "#+begin_src emacs-lisp\n")
    (insert "; child2\n")
    (insert "#+end_src\n")
    (let* ((parent-blocks (lilac-get-parent-blocks))
           (child-parents-hash-table
             (lilac-mk-child-parents-hash-table parent-blocks))
           (smart-captions (lilac-mk-smart-captions
                            child-parents-hash-table)))
      (should (equal smart-captions
       `((91 . ,(concat "#+caption: ="
                         "__NREF__child1"
                         "=  [[parent1][1]]\n"))
         (172 . ,(concat "#+caption: ="
                         "__NREF__child2"
                         "=  [["
                         "__NREF__child1"
                         "][1]]\n"))))))))

6. Glossary

literate document
A file or collection of files that include both source code and prose to explain it. Well-known formats include Noweb files (*.nw) and Org mode files (*.org).
monoblock
an Org mode source code block with a #+name: ... field. This block is an independent block and there are no other blocks with the same name.
Noweb
A literate programming tool from 1989 that still works and from which Org mode borrows heavily using Noweb-style references. See Wikipedia.
noweb-ref
aka "Noweb-style reference". A Noweb-style reference is just a name (string) that refers to a monoblock or polyblock. See the Org manual.
Org mode
An Emacs major mode for *.org files, where "major mode" means that it provides things like syntax highlighting and keyboard shortcuts for *.org text files if you are using Emacs. For Lilac, the important thing is that we use Org mode as a literate programming tool. See Org mode.
polyblock
an Org mode source code block without a #+name: ... field, but which has a #+header: :noweb-ref ... field. Other blocks with the same Noweb-ref name are concatenated together when they are tangled. Polyblocks are used in cases where we would like to break up a single block into smaller pieces for explanatory purposes. In all other cases, monoblocks are preferable, unless the source code block is not to be tangled and is only for explanatory purposes in the woven output.
source code block
An Org mode facility that allows you to enclose a multiline text (typically source code) with #+begin_src ... and #+end_src lines. They are enclosed in a separate background color in the HTML output, and are often used for illustrating source code listings. The format is #+begin_src LANGUAGE_NAME where LANGUAGE_NAME is the name of the programming language used for the listing. If the name is a recognized name, it will get syntax highlighting in the output automatically.
tangling
The act of extracting source code from a raw literate document.
weaving
The act of converting a raw literate document to a richer format such as PDF or HTML. This allows fancier output, such as for mathematical formulas, which are easier to read versus the original literate document.

7. References

[1]
N. Ramsey, “Literate programming simplified,” IEEE Software, vol. 11, no. 5, pp. 97–105, Sep. 1994, doi: 10.1109/52.311070.