diff options
Diffstat (limited to 'site/posts/cleopatra/soupault.org')
-rw-r--r-- | site/posts/cleopatra/soupault.org | 702 |
1 files changed, 702 insertions, 0 deletions
diff --git a/site/posts/cleopatra/soupault.org b/site/posts/cleopatra/soupault.org new file mode 100644 index 0000000..3fdb8d6 --- /dev/null +++ b/site/posts/cleopatra/soupault.org @@ -0,0 +1,702 @@ +#+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 +<nav id="generate-toc"></nav> +#+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 +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 : dependencies-prebuild 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/<<base-dir>>" +doctype = "<!DOCTYPE html>" +clean_urls = false +generator_mode = true +complete_page_selector = "html" +default_content_selector = "main" +default_content_action = "append_child" +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) ~<h1>~ 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 = """<meta name="generator" content="<<soupault-version()>>">""" +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 = "<<base-dir>>" +after = "mark-external-urls" + #+END_SRC + +** Marking External Links + + #+BEGIN_SRC lua :tangle plugins/external-urls.lua +function mark(name) + return '<span class="icon"><svg><use href="/img/icons.svg#' + .. name .. + '"></use></svg></span>' +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 = "insert_html" +selector = "#generate-toc" +action = "prepend_child" +html = '<h2>Table of Contents</h2>' +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 +<div id="history"> + site/posts/FooBar.org +</div> + #+end_src + + This plugin will replace the content of this ~<div>~ 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]{<<repo>>}. + + #+name: repo + #+begin_src verbatim :exports none +https://src.soap.coffee/soap.coffee/lthms.git + #+end_src + + The template used to generate the revision table is the following. + + #+begin_src html :tangle templates/history.html :noweb yes +<details id="history"> + <summary>Revisions</summary> + <p> + This revisions table has been automatically generated + from <a href="<<repo>>">the + <code>git</code> history of this website repository</a>, and the + change descriptions may not always be as useful as they should. + </p> + + <p> + You can consult the source of this file in its current version + <a href="<<repo>>/tree/{{file}}">here</a>. + </p> + + <table class="fullwidth"> + {{#history}} + <tr> + <td class="date" +{{#created}} + id="created-at" +{{/created}} +{{#modified}} + id="modified-at" +{{/modified}} + >{{date}}</td> + <td class="subject">{{subject}}</td> + <td class="commit"> + <a href="<<repo>>/commit/{{filename}}/?id={{hash}}">{{abbr_hash}}</a> + </td> + </tr> + {{/history}} + </table> +</details> + #+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>> +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 + :PROPERTIES: + :CUSTOM_ID: katex + :END: + +*** 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, + ~<span class="imath">\LaTeX</span>~ 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 <h1> 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 “<a href="{{ url }}">{{ title }}</a>.”' +nav_template = '<a href="{{ url }}">{{ title }}</a>' + +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 |