#+TITLE: ~soupault~ #+SERIES: ../cleopatra.html #+SERIES_PREV: ./theme.html #+SERIES_NEXT: ./commands.html We use ~soupault~ to build this website[fn::~soupault~ is an awesome free software project, with a unique approach to static website generation. You should definitely [[https://soupault.app][check out their website]]!]. #+begin_export html #+end_export * Installation We install ~soupault~ in a local switch. We use a witness file ~_opam/.init~ to determine whether or not our switch has always been created during a previous invocation of *~cleopatra~*. #+begin_src makefile :tangle soupault.mk OCAML_VERSION := 4.11.2 OCAML := ocaml-base-compiler.${OCAML_VERSION} CONFIGURE += _opam rss.json ARTIFACTS += out soupault-prebuild : _opam/init #+end_src Using ~soupault~ is as simple as calling it, without any particular command-line arguments. #+begin_src makefile :tangle soupault.mk soupault-build : package-lock.json style.min.css @cleopatra echo "Executing" "soupault" @soupault #+end_src We now describe our configuration file for ~soupault~. * Configuration #+name: base-dir #+begin_src verbatim :noweb yes :exports none ~lthms #+end_src ** Global Settings The options of the ~[settings]~ section of a ~soupault~ configuration are often self-explanatory, and we do not spend too much time to detaul them. #+begin_src toml :tangle soupault.conf :noweb yes [settings] strict = true site_dir = "site" build_dir = "out/<>" doctype = "" clean_urls = false generator_mode = true complete_page_selector = "html" default_content_selector = "main" page_file_extensions = ["html"] ignore_extensions = [ "v", "vo", "vok", "vos", "glob", "html~", "org" ] default_template_file = "templates/main.html" pretty_print_html = false #+end_src ** Setting Page Title We use the “page title” widget to set the title of the webpage based on the first (and hopefully the only) ~

~ tag of the page. #+begin_src toml :tangle soupault.conf [widgets.page-title] widget = "title" selector = "h1" default = "~lthms" prepend = "~lthms: " #+end_src ** Acknowledging ~soupault~ When creating a new ~soupault~ project (using ~soupault --init~), the default configuration file suggests advertising the use of ~soupault~. Rather than hard-coding the used version of ~soupault~ (which is error-prone), we rather determine the version of ~soupault~ with the following script. #+NAME: soupault-version #+begin_src bash :results verbatim output soupault --version | head -n 1 | tr -d '\n' #+end_src The configuration of the widget ---initially provided by ~soupault~--- becomes less subject to the obsolescence[fn::That is, as long as ~soupault~ does not change the output of its ~--version~ option.]. #+begin_src toml :tangle soupault.conf :noweb yes [widgets.generator-meta] widget = "insert_html" html = """""" selector = "head" #+end_src ** Prefixing Internal URLs On the one hand, internal links can be absolute, meaning they start with a leading ~/~, and therefore are relative to the website root. On the other hand, website (especially static website) can be placed in larger context. For instance, my personal website lives inside the ~~lthms~ directory of the ~soap.coffee~ domain[fn::To my experience in hosting webapps and websites, this set-up is way harder to get right than I initially expect.]. The purpose of this plugin is to rewrite internal URLs which are relative to the root, in order to properly prefix them. From a high-level perspective, the plugin structure is the following. First, we validate the widget configuration. #+BEGIN_SRC lua :tangle plugins/urls-rewriting.lua prefix_url = config["prefix_url"] if not prefix_url then Plugin.fail("Missing mandatory field: `prefix_url'") end if not Regex.match(prefix_url, "^/(.*)") then prefix_url = "/" .. prefix_url end if not Regex.match(prefix_url, "(.*)/$") then prefix_url = prefix_url .. "/" end #+END_SRC Then, we propose a generic function to enumerate and rewrite tags which can have. #+BEGIN_SRC lua :tangle plugins/urls-rewriting.lua function prefix_urls (links, attr, prefix_url) index, link = next(links) while index do href = HTML.get_attribute(link, attr) if href then if Regex.match(href, "^/") then href = Regex.replace(href, "^/*", "") href = prefix_url .. href end HTML.set_attribute(link, attr, href) end index, link = next(links, index) end end #+END_SRC Finally, we use this generic function for relevant tags. #+BEGIN_SRC lua :tangle plugins/urls-rewriting.lua prefix_urls(HTML.select(page, "a"), "href", prefix_url) prefix_urls(HTML.select(page, "link"), "href", prefix_url) prefix_urls(HTML.select(page, "img"), "src", prefix_url) prefix_urls(HTML.select(page, "script"), "src", prefix_url) prefix_urls(HTML.select(page, "use"), "href", prefix_url) #+END_SRC Again, configuring soupault to use this plugin is relatively straightforward. #+BEGIN_SRC toml :tangle soupault.conf :noweb yes [widgets.urls-rewriting] widget = "urls-rewriting" prefix_url = "<>" after = "mark-external-urls" #+END_SRC ** Marking External Links #+BEGIN_SRC lua :tangle plugins/external-urls.lua function mark(name) return '' end links = HTML.select(page, "a") index, link = next(links) while index do href = HTML.get_attribute(link, "href") if href then if Regex.match(href, "^https?://github.com") then icon = HTML.parse(mark("github")) HTML.append_child(link, icon) elseif Regex.match(href, "^https?://") then icon = HTML.parse(mark("external-link")) HTML.append_child(link, icon) end end index, link = next(links, index) end #+END_SRC #+BEGIN_SRC toml :tangle soupault.conf [widgets.mark-external-urls] after = "generate-history" widget = "external-urls" #+END_SRC ** Generating a Table of Contents The ~toc~ widget allows for generating a table of contents for HTML files which contains a node matching a given ~selector~ (in the case of this document, ~#generate-toc~). #+begin_src toml :tangle soupault.conf [widgets.table-of-contents] widget = "toc" selector = "#generate-toc" action = "replace_content" valid_html = true min_level = 2 max_level = 3 numbered_list = false heading_links = true heading_link_text = " §" heading_links_append = true heading_link_class = "anchor-link" [widgets.append-toc-title] widget = "preprocess_element" selector = "#generate-toc" command = 'echo "

Table of Contents

$(cat)"' after = "table-of-contents" #+end_src ** Generating Per-File Revisions Tables *** Users Instructions This widgets allows to generate a so-called “revisions table” of the filename contained in a DOM element of id ~history~, based on its history. Paths should be relative to the directory from which you start the build process (typically, the root of your repository). The revisions table notably provides hyperlinks to a ~git~ webview for each commit. For instance, considering the following HTML snippet #+begin_src html
site/posts/FooBar.org
#+end_src This plugin will replace the content of this ~
~ with the revisions table of ~site/posts/FooBar.org~. *** Customization The base of the URL webview for the document you are currently reading is src_verbatim[:noweb yes :exports code]{<>}. #+name: repo #+begin_src verbatim :exports none https://code.soap.coffee/writing/lthms.git #+end_src The template used to generate the revision table is the following. #+begin_src html :tangle templates/history.html :noweb yes
Revisions

This revisions table has been automatically generated from the git history of this website repository, and the change descriptions may not always be as useful as they should.

You can consult the source of this file in its current version here.

{{#history}} {{/history}}
{{date}} {{subject}} {{abbr_hash}}
#+end_src *** Implementation We use the built-in [[https://soupault.neocities.org/reference-manual/#widgets-preprocess-element][=preprocess_element=]] to implement, which means we need a script which gets its input from the standard input, and echoes its output to the standard input. #+begin_src toml :tangle soupault.conf [widgets.generate-history] widget = "preprocess_element" selector = "#history" command = 'scripts/history.sh templates/history.html' action = "replace_element" #+end_src This plugin proceeds as follows: 1. Using an ad-hoc script, it generates a JSON containing for each revision - The subject, date, hash, and abbreviated hash of the related commit - The name of the file at the time of this commit 2. This JSON is passed to a mustache engine (~haskell-mustache~) with a proper template 3. The content of the selected DOM element is replaced with the output of ~haskell-mustache~ This translates in Bash like this. #+begin_src bash :tangle scripts/history.sh :shebang "#!/usr/bin/bash" function main () { local file="${1}" local template="${2}" tmp_file=$(mktemp) generate_json ${file} > ${tmp_file} haskell-mustache ${template} ${tmp_file} rm ${tmp_file} } #+end_src Generating the expected JSON is therefore as simple as: - Fetching the logs - Reading 8 line from the logs, parse the filename from the 6th line - Outputing the JSON We will use ~git~ to get the information we need. By default, ~git~ subcommands use a pager when its output is likely to be long. This typically includes ~git-log~. To disable this behavior, ~git~ exposes the ~--no-pager~ command. Besides, we also need ~--follow~ and ~--stat~ to deal with file renaming. Without this option, ~git-log~ stops when the file first appears in the repository, even if this “creation” is actually a renaming. Therefore, the ~git~ command line we use to collect our history is #+name: gitlog #+begin_src bash :tangle scripts/history.sh :noweb yes function gitlog () { local file="${1}" git --no-pager log \ --follow \ --stat=10000 \ --pretty=format:'%s%n%h%n%H%n%cs%n' \ "${file}" } #+end_src This function will generate a sequence of 8 lines containing all the relevant information we are looking for, for each commit, namely: - Subject - Abbreviated hash - Full hash - Date - Empty line - Change summary - Shortlog - Empty line For instance, the =gitlog= function will output the following lines for the last commit of this very file: #+begin_src bash :results verbatim :exports results :noweb yes <> gitlog "soupault.org" | head -n8 #+end_src Among other things, the 6th line contains the filename. We need to extract it, and we do that with ~sed~. In case of file renaming, we need to parse something of the form ~both/to/{old => new}~. #+begin_src bash :tangle scripts/history.sh :noweb yes function parse_filename () { local line="${1}" local shrink='s/ *\(.*\) \+|.*/\1/' local unfold='s/\(.*\){\(.*\) => \(.*\)}/\1\3/' echo ${line} | sed -e "${shrink}" | sed -e "${unfold}" } #+end_src The next step is to process the logs to generate the expected JSON. We have to deal with the fact that JSON does not allow the last item of an array to be concluded by ",". Besides, we also want to indicate which commit is responsible for the creation of the file. To do that, we use two variables: =idx= and =last_entry=. When =idx= is equal to 0, we know it is the latest commit. When =idx= is equal to =last_entry=, we know we are looking at the oldest commit for that file. #+begin_src bash :tangle scripts/history.sh :noweb yes function generate_json () { local input="${1}" local logs="$(gitlog ${input})" if [ ! $? -eq 0 ]; then exit 1 fi let "idx=0" let "last_entry=$(echo "${logs}" | wc -l) / 8" local subject="" local abbr_hash="" local hash="" local date="" local file="" local created="true" local modified="false" echo -n "{" echo -n "\"file\": \"${input}\"" echo -n ",\"history\": [" while read -r subject; do read -r abbr_hash read -r hash read -r date read -r # empty line read -r file read -r # short log read -r # empty line if [ ${idx} -ne 0 ]; then echo -n "," fi if [ ${idx} -eq ${last_entry} ]; then created="true" modified="false" else created="false" modified="true" fi output_json_entry "${subject}" \ "${abbr_hash}" \ "${hash}" \ "${date}" \ "$(parse_filename "${file}")" \ "${created}" \ "${modified}" let idx++ done < <(echo "${logs}") echo -n "]}" } #+end_src Generating the JSON object for a given commit is as simple as #+begin_src bash :tangle scripts/history.sh :noweb yes function output_json_entry () { local subject="${1}" local abbr_hash="${2}" local hash="${3}" local date="${4}" local file="${5}" local created="${6}" local last_entry="${7}" echo -n "{\"subject\": \"${subject}\"" echo -n ",\"created\":${created}" echo -n ",\"modified\":${modified}" echo -n ",\"abbr_hash\":\"${abbr_hash}\"" echo -n ",\"hash\":\"${hash}\"" echo -n ",\"date\":\"${date}\"" echo -n ",\"filename\":\"${file}\"" echo -n "}" } #+end_src And we are done! We can safely call the =main= function to generate our revisions table. #+begin_src bash :tangle scripts/history.sh main "$(cat)" "${1}" #+end_src ** Rendering Equations Offline *** Users instructions Inline equations written in the DOM under the class src_css{.imath} and using the \im \LaTeX \mi syntax can be rendered once and for all by ~soupault~. User For instance, ~\LaTeX~ is rendered \im \LaTeX \mi as expected. Using this widgets requires being able to inject raw HTML in input files. *** Implementation #+begin_src js :tangle scripts/render-equations.js var katex = require("katex"); var fs = require("fs"); var input = fs.readFileSync(0); var displayMode = process.env.DISPLAY != undefined; var html = katex.renderToString(String.raw`${input}`, { throwOnError : false, displayModed : displayMode }); console.log(html) #+end_src We reuse once again the =preprocess_element= widget. The selector is ~.imath~ (~i~ stands for inline in this context), and we replace the previous content with the result of our script. #+begin_src toml :tangle soupault.conf [widgets.inline-math] widget = "preprocess_element" selector = ".imath" command = "node scripts/render-equations.js" action = "replace_content" [widgets.display-math] widget = "preprocess_element" selector = ".dmath" command = "DISPLAY=1 node scripts/render-equations.js" action = "replace_content" #+end_src ** RSS Feed #+begin_src toml :tangle soupault.conf [index] index = true dump_json = "rss.json" extract_after_widgets = ["urls-rewriting"] [index.fields] title = { selector = ["h1"] } modified-at = { selector = ["#modified-at"] } created-at = { selector = ["#created-at"] } #+end_src ** Series Navigation #+begin_src lua :tangle plugins/series.lua function get_title_from_path (path) if Sys.is_file(path) then local content_raw = Sys.read_file(path) local content_dom = HTML.parse(content_raw) local title = HTML.select_one(content_dom, "h1") if title then return String.trim(HTML.inner_html(title)) else Plugin.fail(path .. ' has no

tag') end else Plugin.fail(path .. ' is not a file') end end #+end_src #+begin_src lua :tangle plugins/series.lua function generate_nav_item_from_title (title, url, template) local env = {} env["url"] = url env["title"] = title local new_content = String.render_template(template, env) return HTML.parse(new_content) end #+end_src #+begin_src lua :tangle plugins/series.lua function generate_nav_items (cwd, cls, template) local elements = HTML.select(page, cls) local i = 1 while elements[i] do local element = elements[i] local url = HTML.strip_tags(element) local path = Sys.join_path(cwd, url) local title_str = get_title_from_path(path) HTML.replace_content( element, generate_nav_item_from_title(title_str, url, template) ) i = i + 1 end end #+end_src #+begin_src lua :tangle plugins/series.lua cwd = Sys.dirname(page_file) home_template = 'This article is part of the series “{{ title }}.”' nav_template = '{{ title }}' generate_nav_items(cwd, ".series", home_template) generate_nav_items(cwd, ".series-prev", nav_template) generate_nav_items(cwd, ".series-next", nav_template) #+end_src #+begin_src toml :tangle soupault.conf [widgets.series] widget = "series" #+end_src ** Injecting Minified CSS #+begin_src lua :tangle plugins/css.lua style = HTML.select_one(page, "style") if style then css = HTML.create_text(Sys.read_file("style.min.css")) HTML.replace_content(style, css) end #+end_src #+begin_src toml :tangle soupault.conf [widgets.css] widget = "css" #+end_src ** Cleaning-up #+begin_src lua :tangle plugins/clean-up.lua function remove_if_empty(html) if String.trim(HTML.inner_html(html)) == "" then HTML.delete(html) end end #+end_src #+begin_src lua :tangle plugins/clean-up.lua function remove_all_if_empty(cls) local elements = HTML.select(page, cls) local i = 1 while elements[i] do local element = elements[i] remove_if_empty(element) i = i + 1 end end #+end_src #+begin_src lua :tangle plugins/clean-up.lua remove_all_if_empty("p") -- introduced by org-mode remove_all_if_empty("div.code") -- introduced by coqdoc #+end_src #+begin_src toml :tangle soupault.conf [widgets.clean-up] widget = "clean-up" #+end_src