]> git.plutz.net Git - cookieproxy/commitdiff
Squashed 'cgilite/' content from commit 1ac47c1
authorPaul Hänsch <paul@plutz.net>
Mon, 27 Oct 2025 10:44:04 +0000 (11:44 +0100)
committerPaul Hänsch <paul@plutz.net>
Mon, 27 Oct 2025 10:44:04 +0000 (11:44 +0100)
git-subtree-dir: cgilite
git-subtree-split: 1ac47c1fc5f20832245d950b67f6be8f8da4172d

14 files changed:
.gitignore [new file with mode: 0644]
cgilite.awk [new file with mode: 0644]
cgilite.sh [new file with mode: 0755]
common.css [new file with mode: 0644]
db23.sh [new file with mode: 0755]
file.sh [new file with mode: 0755]
html-sh.sed [new file with mode: 0755]
json.sh [new file with mode: 0755]
logging.sh [new file with mode: 0755]
markdown.awk [new file with mode: 0755]
session.sh [new file with mode: 0755]
storage.sh [new file with mode: 0755]
tests-markdown.sh [new file with mode: 0755]
users.sh [new file with mode: 0755]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..5c9950a
--- /dev/null
@@ -0,0 +1,3 @@
+cgilite
+serverkey
+users.db
diff --git a/cgilite.awk b/cgilite.awk
new file mode 100644 (file)
index 0000000..ebf4411
--- /dev/null
@@ -0,0 +1,161 @@
+#!/bin/env awk -f
+
+function debug(t) { printf "%s\n", t >>"/dev/stderr"; }
+
+function PATH( str,    seg, out ) {
+  while ( str ) {
+    seg = str;
+    sub( /\/.*$/, "", seg);
+    sub( /^[^\/]*\//, "", str);
+    if      ( seg == ".." )  sub(/\/[^\/]*\/?$/, "", out);
+    else if ( seg ~ /^\.?$/) sub(/\/?$/, "/", out);
+    else sub(/\/?$/, "/" seg, out);
+
+    if (seg == str) break;
+  }
+  if (!(str && out)) sub(/\/?$/,"/" out);
+  return out;
+}
+
+function HEX_DECODE( pfx, inp,    out, n, k ) {
+  k = length(pfx);
+  gsub(/[].*+?^${}()|\\[]/,"\\\\&",pfx);
+  while ( inp ) if ( n = match(inp, pfx "[0-9a-fA-F][0-9a-fA-F]") ) {
+    out = out substr(inp, 1, n - 1);
+    inp = substr(inp, n + k);
+    if      (inp ~  /^[0-9]/) n = 16 * substr(inp, 1, 1);
+    else if (inp ~  /^[aA]/)  n = 160;
+    else if (inp ~  /^[bB]/)  n = 176;
+    else if (inp ~  /^[cC]/)  n = 192;
+    else if (inp ~  /^[dD]/)  n = 208;
+    else if (inp ~  /^[eE]/)  n = 224;
+    else if (inp ~  /^[fF]/)  n = 240;
+    if      (inp ~ /^.[0-9]/) n += substr(inp, 2, 1);
+    else if (inp ~ /^.[aA]/)  n += 10;
+    else if (inp ~ /^.[bB]/)  n += 11;
+    else if (inp ~ /^.[cC]/)  n += 12;
+    else if (inp ~ /^.[dD]/)  n += 13;
+    else if (inp ~ /^.[eE]/)  n += 14;
+    else if (inp ~ /^.[fF]/)  n += 15;
+    out = out sprintf("%c", n);
+    inp = substr(inp, 3);
+  } else {
+    out = out inp;
+    break;
+  }
+  return out;
+}
+
+function HTML( text ) {
+  gsub( /&/,  "\\&amp;",  text );
+  gsub( /</,  "\\&lt;",   text );
+  gsub( />/,  "\\&gt;",   text );
+  gsub( /"/,  "\\&quot;", text );
+  gsub( /'/,  "\\&#x27;", text );
+  gsub( /\[/, "\\&#x5B;", text );
+  gsub( /\]/, "\\&#x5D;", text );
+  gsub( /\r/, "\\&#x0D;", text );
+  gsub( /\n/, "\\&#x0A;", text );
+  gsub( /\\/, "\\&#x5C;", text );
+  return text;
+}
+
+function URL( text ) {
+  gsub( /&/,  "%26", text );
+  gsub( /"/,  "%22", text );
+  gsub( /'/,  "%27", text );
+  gsub( /`/,  "%60", text );
+  gsub( /\?/, "%3F", text );
+  gsub( /#/,  "%23", text );
+  gsub( /\[/, "%5B", text );
+  gsub( /\]/, "%5D", text );
+  gsub( / /,  "%20", text );
+  gsub( /\t/, "%09", text );
+  gsub( /\r/, "%0D", text );
+  gsub( /\n/, "%0A", text );
+  gsub( /%/,  "%25", text );
+  gsub( /\\/, "%5C", text );
+  return text;
+}
+
+function _cgilite_urldecode( str, arr, spl,   form, k, n, key) {
+  if (! spl) spl="&"
+  split(str, form, spl);
+  for ( k in form ) {
+    key = form[k]; sub(/=.*$/, "", key);
+    sub(/^[^=]*=/, "", form[k]);
+    if ( key in arr ) {
+      n = 1; while ( (key, n) in arr ) n++;
+      arr[key,n] = HEX_DECODE( "%", form[k]);
+    } else {
+      arr[key] = HEX_DECODE( "%", form[k]);
+    }
+  }
+}
+
+function _cgilite_request(    key, val) {
+  # Read request from client connection
+
+  # Read Headers
+  getline; REQUEST_METHOD = $1; REQUEST_URI = $2; SERVER_PROTOCOL = $3;
+  while ( getline ) {
+    if ($0 ~ /^\r?$/) break;
+    else if ($0 ~ /^[a-zA-Z][0-9a-zA-Z_-]+: .*/) {
+      key = toupper($0);
+      sub(/:.*$/, "", key);
+      gsub(/-/, "_", key);
+      _HEADER[key] = $0;
+      sub(/^[^:]:[\t ]*/, "", _HEADER[key]);
+      sub(/[\t ]*\r?$/, "", _HEADER[key]);
+    }
+  }
+  CONTENT_LENGTH = _HEADER["CONTENT_LENGTH"];
+  CONTENT_TYPE   = _HEADER["CONTENT_TYPE"];
+
+  PATH_INFO = REQUEST_URI; gsub(/\?.*$/, "", PATH_INFO)
+  PATH_INFO = PATH( HEX_DECODE( "%", PATH_INFO ) );
+  QUERY_STRING = REQUEST_URI;
+  if ( !gsub(/^[^?]+\?/, "", QUERY_STRING) ) QUERY_STRING = "";
+
+  # Set up _GET[]-Array
+  _cgilite_urldecode(QUERY_STRING, _GET);
+
+  if ( _HEADER["CONTENT_TYPE"] == "application/x-www-form-urlencoded" \
+       && _HEADER["CONTENT_LENGTH"] ) {
+    # Set up _POST[]-Array
+
+    val = ""; key = "head -c " _HEADER["CONTENT_LENGTH"];
+    while (key |getline) val = val $0; close(key);
+    _cgilite_urldecode(val, _POST);
+  }
+
+  if ( _HEADER["COOKIE"] ) {
+    # Set up _COOKIE[]-Array
+    _cgilite_urldecode(_HEADER["COOKIE"], _COOKIE, "; ?");
+  }
+
+  if ( _HEADER["REFERER"] ) {
+    key = HEADER["REFERER"];
+    if (! sub(/^[^\?]+?/, "", key)) key = ""
+    _cgilite_urldecode(key, _REF);
+  }
+
+}
+
+function _cgilite_headers() {
+  # Import request data from webserver environment variables
+}
+
+BEGIN {
+  REQUEST_METHOD=""; REQUEST_URI=""; SERVER_PROTOCOL="";
+  PATH_INFO=""; QUERY_STRING=""; CONTENT_LENGTH=""; CONTENT_TYPE="";
+  split("", _GET); split("", _POST); split("", _REF);
+  split("", _HEADER); split("", _COOKIE);
+
+  if ( ENVIRON["REQUEST_METHOD"] ) {
+    _cgilite_headers();
+  } else {
+    _cgilite_request();
+  }
+}
diff --git a/cgilite.sh b/cgilite.sh
new file mode 100755 (executable)
index 0000000..b2467c3
--- /dev/null
@@ -0,0 +1,356 @@
+#!/bin/sh
+
+# This is CGIlite.
+# A collection of posix shell functions for writing CGI scripts.
+
+# Copyright 2017 - 2023 Paul Hänsch
+# 
+# Permission to use, copy, modify, and/or distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+# 
+# THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+# SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
+# IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+[ -n "$include_cgilite" ] && return 0
+# guard set after webserver part
+
+# ksh and zsh workaround
+# set -o posix # ksh, not portable
+setopt -o OCTAL_ZEROES 2>&-
+
+# Integrated webserver request timeout
+cgilite_timeout=2
+
+# General environment variables
+# $_EXEC - directory containing application itself
+# $_DATA - direcotry where application data may be stored
+# $_BASE - optional prefix for http path, e.g. "/myapp"
+#
+# Programmers should take care to use those variables throughout the
+# application.
+# Variables may be set via CLI argument, in environment, or left as default.
+
+for cgilite_arg in "$@"; do case $cgilite_arg in
+  --exec=*) _EXEC="${cgilite_arg#*=}";;
+  --data=*) _DATA="${cgilite_arg#*=}";;
+  --base=*) _BASE="${cgilite_arg#*=}";;
+esac; done
+unset cgilite_arg
+
+_EXEC="${_EXEC:-${0%/*}}"
+_DATA="${_DATA:-.}"
+_EXEC="${_EXEC%/}" _DATA="${_DATA%/}" _BASE="${_BASE%/}"
+
+export _EXEC _DATA _BASE
+
+# Carriage Return and Line Break characters for convenience
+CR="\r"
+BR='
+'
+
+PATH(){ 
+  local str seg out
+  # normalize path
+  # read from stdin if no arguments are provided
+
+  [ $# -eq 0 ] && str="$(cat)" || str="$*"
+  while [ "$str" ]; do
+    seg=${str%%/*}; str="${str#*/}"
+    case $seg in
+      ..) out="${out%/}"; out="${out%/*}/";;
+    .|'') out="${out%/}/";;
+       *) out="${out%/}/${seg}";;
+    esac;
+    [ "$seg" = "$str" ] && break
+  done
+  [ "${str}" -a "${out}" ] && printf %s "$out" || printf %s/ "${out%/}"
+}
+
+HEX_DECODE(){
+  local pfx="$1" in="$2" out
+  # Print out Data encoded as Hex
+  #
+  # Arguments:
+  # pfx - required, prefix for a hex tupel, e.g. "\x", "%" "\", may be empty
+  # in - required, string to be decoded
+  #
+  # anything that does not constitute a tupel of valid Hex numerals
+  # will be copied to the output literally
+
+  while [ "$in" ]; do
+    [ "$pfx" ] || case $in in
+      [0-9a-fA-F][0-9a-fA-F]*):;;
+      ?*) out="${out}${in%%"${in#?}"}"
+          in="${in#?}"; continue;;
+    esac
+
+    case $in in
+      "$pfx"[0-9a-fA-F][0-9a-fA-F]*) in="${in#"${pfx}"}";;
+      \\*) in="${in#?}"; out="${out}\\\\"; continue;;
+       %*) in="${in#?}"; out="${out}%%";  continue;;
+        *) att="${in%%"${pfx}"*}"; att="${att%%%*}"; att="${att%%\\*}"
+           out="${out}${att}"; in="${in#"${att}"}"; continue;;
+    esac;
+
+    # Hex escapes for printf (e.g. \x41) are not portable 
+    # The portable way for Hex output is transforming Hex to Octal
+    # (e.g. \x41 = \101)
+    case $in in
+        [0123]?*) out="${out}\\0";;
+        [4567]?*) out="${out}\\1";;
+      [89aAbB]?*) out="${out}\\2";;
+      [c-fC-F]?*) out="${out}\\3";;
+    esac
+    case $in in
+            [048cC][0-7]*) out="${out}0";;
+       [048cC][89a-fA-F]*) out="${out}1";;
+            [159dD][0-7]*) out="${out}2";;
+       [159dD][89a-fA-F]*) out="${out}3";;
+           [26aAeE][0-7]*) out="${out}4";;
+      [26aAeE][89a-fA-F]*) out="${out}5";;
+           [37bBfF][0-7]*) out="${out}6";;
+      [37bBfF][89a-fA-F]*) out="${out}7";;
+    esac
+    case $in in
+       ?[08]*) out="${out}0";;
+       ?[19]*) out="${out}1";;
+      ?[2aA]*) out="${out}2";;
+      ?[3bB]*) out="${out}3";;
+      ?[4cC]*) out="${out}4";;
+      ?[5dD]*) out="${out}5";;
+      ?[6eE]*) out="${out}6";;
+      ?[7fF]*) out="${out}7";;
+    esac
+    in="${in#?}"
+    in="${in#?}"
+  done
+  printf -- "$out"
+}
+
+if [ -z "$REQUEST_METHOD" ]; then
+  # no webserver variables means we are running via inetd / ncat
+  # so use builtin web server
+
+  # Use env from inetd as webserver variables
+  REMOTE_ADDR="${TCPREMOTEIP}"
+  SERVER_NAME="${TCPLOCALIP}"
+  SERVER_PORT="${TCPLOCALPORT}"
+
+  # Wait 2 seconds for request or kill connection through watchdog.
+  # Once Request is received the watchdog will be suspended (killed).
+  # At the end of the loop the watchdog will be restarted to enable
+  # timeout for the subsequent request.
+
+  (sleep $cgilite_timeout && kill $$) & cgilite_watchdog=$!
+  while read REQUEST_METHOD REQUEST_URI SERVER_PROTOCOL; do
+    unset PATH_INFO QUERY_STRING cgilite_headers CONTENT_LENGTH CONTENT_TYPE
+
+    [ "${SERVER_PROTOCOL#HTTP/1.[01]${CR}}" ] && break
+    kill $cgilite_watchdog
+
+    SERVER_PROTOCOL="${SERVER_PROTOCOL%${CR}}"
+    PATH_INFO="$(HEX_DECODE % "${REQUEST_URI%\?*}" |PATH)"
+    [ "${REQUEST_URI}" = "${REQUEST_URI#*\?}" ] \
+    && QUERY_STRING='' \
+    || QUERY_STRING="${REQUEST_URI#*\?}"
+    while read -r hl; do
+      hl="${hl%${CR}}"; [ "$hl" ] || break
+      case $hl in
+        'Content-Length: '*) CONTENT_LENGTH="${hl#*: }";;
+          'Content-Type: '*)   CONTENT_TYPE="${hl#*: }";;
+      esac
+      cgilite_headers="${cgilite_headers}${hl}${BR}"
+    done
+
+    export REMOTE_ADDR SERVER_NAME SERVER_PORT REQUEST_METHOD REQUEST_URI SERVER_PROTOCOL \
+           PATH_INFO QUERY_STRING CONTENT_TYPE CONTENT_LENGTH cgilite_headers
+
+    # Try to serve multiple requests, provided that script serves a
+    # Content-Length header.
+    # Without Content-Length header, connection will terminate after
+    # script.
+
+    cgilite_status='200 OK'; cgilite_response=''; cgilite_cl="Connection: close${CR}${BR}";
+    . "$0" | while read -r l; do case $l in
+      Status:*)
+        cgilite_status="${l#Status: }";;
+      Content-Length:*)
+        cgilite_cl=""
+        cgilite_response="${cgilite_response:+${cgilite_response}${BR}}${l}";;
+      Connection:*)
+        cgilite_cl="${l}${BR}";;
+      $CR) printf '%s %s\r\n%s%s\r\n' \
+             'HTTP/1.1' "${cgilite_status%${CR}}" \
+             "${cgilite_response}${cgilite_response:+${BR}}" "${cgilite_cl}"
+           cat || kill $$
+           [ "${cgilite_cl#Connection}" = "${cgilite_cl}" ]; exit;;
+      *) cgilite_response="${cgilite_response:+${cgilite_response}${BR}}${l}";;
+    esac; done || exit 0;
+    (sleep $cgilite_timeout && kill $$) & cgilite_watchdog=$!
+  done
+  kill $cgilite_watchdog
+  exit 0
+fi
+
+include_cgilite="$0"
+
+if [ "${REQUEST_METHOD}" = POST -a "${CONTENT_LENGTH:-0}" -gt 0 -a \
+     "${CONTENT_TYPE}" = "application/x-www-form-urlencoded" ]; then
+  cgilite_post="$(head -c "$CONTENT_LENGTH")"
+fi
+
+PATH_INFO="$(PATH "/${PATH_INFO#${_BASE}}")"
+
+debug(){ [ $# -gt 0 ] && printf '%s\n' "$@" >&2 || tee -a /dev/stderr; }
+[ "${DEBUG+x}" ] && env >&2
+
+# general helper functions, see GET, POST, and REF below
+
+cgilite_count(){
+  printf %s "&$1" \
+  | grep -oE '&'"$2"'=[^&]*' \
+  | wc -l
+}
+
+cgilite_value(){
+  local str="&$1" name="$2" cnt="${3:-1}"
+  while [ $cnt -gt 0 ]; do
+    [ "${str}" = "${str#*&${name}=}" ] && return 1
+    str="${str#*&${name}=}"
+    cnt=$((cnt - 1))
+  done
+  HEX_DECODE % "$(printf %s "${str%%&*}" |tr + \  )"
+}
+
+cgilite_keys(){
+  local str="&$1"
+  while [ "${str#*&}" != "${str}" ]; do
+    str="${str#*&}"
+    printf '%s\n' "${str%%=*}"
+  done \
+  | sort -u
+}
+
+# Read arguments from GET, POST, or the query string of the referrer (REF).
+# Example:
+# GET varname n
+#
+# where n is number for the Nth occurence of a variable and defaults to 1
+#
+# *_COUNT varname
+# -> returns number of ocurences
+# *_KEYS
+# -> returns list of available varnames
+
+GET(){ cgilite_value "${QUERY_STRING}" "$@"; }
+GET_COUNT(){ cgilite_count "${QUERY_STRING}" $1; }
+GET_KEYS(){ cgilite_keys "${QUERY_STRING}"; }
+
+POST(){ cgilite_value "${cgilite_post}" "$@"; }
+POST_COUNT(){ cgilite_count "${cgilite_post}" $1; }
+POST_KEYS(){ cgilite_keys "${cgilite_post}"; }
+
+REF(){ cgilite_value "${HTTP_REFERER#*\?}" "$@"; }
+REF_COUNT(){ cgilite_count "${HTTP_REFERER#*\?}" $1; }
+REF_KEYS(){ cgilite_keys "${HTTP_REFERER#*\?}"; }
+
+HEADER(){
+  # Read value of header line. Use this instead of
+  # referencing HTTP_* environment variables.
+  if [ -n "${cgilite_headers+x}" ]; then
+    local str="${BR}${cgilite_headers}"
+    [ "${str}" = "${str#*${BR}${1}: }" ] && return 1
+    str="${str#*${BR}${1}: }"
+    printf %s "${str%%${BR}*}"
+  else
+    local var="HTTP_$(printf %s "$1" |tr a-z- A-Z_)"
+    eval "[ \"\$$var\" ] && printf %s \"\$$var\" || return 1"
+    # eval "printf %s \"\$HTTP_$(printf %s "${1}" |tr a-z A-Z |tr -c A-Z _)\""
+  fi
+}
+
+COOKIE(){
+  # Read value of cookie
+  HEX_DECODE % "$(
+    HEADER Cookie \
+    | grep -oE '(^|; ?)'"$1"'=[^;]*' \
+    | sed -En "${2:-1}"'{s;^[^=]+=;;; s;\+; ;g; p;}'
+  )"
+}
+
+HTML(){
+  # Escape HTML cahracters
+  # Also escape [, ], and \n for use in html-sh
+  local str out
+  [ $# -eq 0 ] && str="$(cat)" || str="$*"
+  while [ "$str" ]; do case $str in
+    \&*) out="${out}&amp;";       str="${str#?}";;
+    \<*) out="${out}&lt;";        str="${str#?}";;
+    \>*) out="${out}&gt;";        str="${str#?}";;
+    \"*) out="${out}&quot;";      str="${str#?}";;
+    \'*) out="${out}&#x27;";      str="${str#?}";;
+    \[*) out="${out}&#x5B;";      str="${str#?}";;
+    \]*) out="${out}&#x5D;";      str="${str#?}";;
+    "${CR}"*) out="${out}&#x0D;"; str="${str#?}";;
+    "${BR}"*) out="${out}&#x0A;"; str="${str#?}";;
+    *) out="${out}${str%%[]&<>\"\'${CR}${BR}[]*}"; str="${str#"${str%%[]&<>\"\'${CR}${BR}[]*}"}";;
+  esac; done
+  printf %s "$out"
+}
+
+URL(){
+  # Escape pathes, so they can be used in link tags and HTTP Headers
+  local str out
+  [ $# -eq 0 ] && str="$(cat)" || str="$*"
+  while [ "$str" ]; do case $str in
+    \&*) out="${out}%26"; str="${str#?}";;
+    \"*) out="${out}%22"; str="${str#?}";;
+    \'*) out="${out}%27"; str="${str#?}";;
+    \`*) out="${out}%60"; str="${str#?}";;
+    \?*) out="${out}%3F"; str="${str#?}";;
+    \#*) out="${out}%23"; str="${str#?}";;
+    \[*) out="${out}%5B"; str="${str#?}";;
+    \]*) out="${out}%5D"; str="${str#?}";;
+    \ *) out="${out}%20"; str="${str#?}";;
+    "  "*) out="${out}%09"; str="${str#?}";;
+    "${CR}"*) out="${out}%0D"; str="${str#?}";;
+    "${BR}"*) out="${out}%0A"; str="${str#?}";;
+    %*) out="${out}%25"; str="${str#?}";;
+    *) out="${out}${str%%[]&\"\'\?#    ${CR}${BR}%[]*}"; str="${str#"${str%%[]&\"\'\?#         ${CR}${BR}%[]*}"}";;
+  esac; done
+  printf %s "$out"
+}
+
+SET_COOKIE(){
+  # Param: session | +seconds | [date]
+  # Param: name=value
+  # Param: Path= | Domain= | Secure
+  local expire cookie
+  case "$1" in
+    ''|0|session) expire='';;
+    [+-][0-9]*)   expire="$(date -R -d @$(($(date +%s) + $1)))";;
+    *)            expire="$(date -R -d "$1")";;
+  esac
+  cookie="$2"
+
+  printf 'Set-Cookie: %s; HttpOnly; SameSite=Lax' "$cookie"
+  [ -n "$expire" ] && printf '; Expires=%s' "${expire%+????}${expire:+GMT}"
+  [ $# -ge 3 ] && shift 2 && printf '; %s' "$@"
+  printf '\r\n'
+}
+
+REDIRECT(){
+  # Trigger redirct and terminate script
+  printf '%s: %s\r\n' \
+    Status "303 See Other" \
+    Content-Length 0 \
+    Location "$*"
+  printf '\r\n'
+  exit 0
+}
diff --git a/common.css b/common.css
new file mode 100644 (file)
index 0000000..16e99f2
--- /dev/null
@@ -0,0 +1,191 @@
+/* ======= GENERIC HTML STYLES ======= */
+
+* {
+  box-sizing: border-box;
+  position: relative;
+  font: inherit;
+  text-decoration: inherit;
+  color: inherit; background: transparent;
+  max-width: 100%;
+  margin: 0; padding: 0;
+  border: none;
+}
+
+body {
+  font: normal normal normal medium/1.5em sans-serif;
+  color: #000; background: #FFF;
+}
+
+ul, ol, dl, table, pre, p { margin-bottom: .5em; }
+p:only-child { margin-bottom: 0; }
+
+table {
+  max-width: 100%;
+  overflow-x: auto;
+}
+th, td { padding: .25em .75em; }
+
+a {
+  font-style: italic;
+  text-decoration: underline;
+  color: #068;
+  word-break: break-word;
+}
+a.button, label.button {
+  font-style: inherit;
+  text-decoration: inherit;
+  color: inherit;
+}
+
+sup { vertical-align: super; }
+sub { vertical-align: sub; }
+small { font-size: .75em; }
+big   { font-size: 1.25em; }
+strike, del, s { text-decoration: line-through; }
+u {text-decoration: underline; }
+i, em { font-style: italic; }
+b, strong { font-weight: bolder; }
+tt, code, var, samp, kbd { font-family: monospace; }
+kbd { font-style: italic; }
+
+blockquote {
+  background-color: #EEE;
+  margin: .5em 0;
+  padding: 1em 2em;
+  white-space: pre-line;
+}
+
+ul, ol { padding-left: 1.5em; }
+dl dt { font-weight: bolder; }
+dl dd {
+  margin: 0 2em;
+  background-color: #EEE;
+}
+table th { font-weight: bold; }
+
+li p + ul, li p + ol {
+  margin-top: -.25em;
+}
+
+hr { border-bottom: 1pt solid; }
+
+h1, h2, h3 {
+  font-weight: bold;
+  margin-top: .75em;
+  margin-bottom: .5em;
+}
+
+h4, h5, h6, form legend {
+  font-weight: bolder;
+  margin-bottom: .25em;
+}
+
+h1 {
+  text-align: center;
+  font-size: 1.5em;
+}
+h2 { font-size: 1.125em; }
+
+select, input, button, textarea, a.button, label.button {
+  display: inline-block;
+  color: #000; background-color: #FFF;
+  border: .5pt solid;
+  padding: .25em .75em;
+  vertical-align: text-bottom;
+  border: .5pt solid #000;
+  border-radius: 2pt;
+}
+select { padding: .375em 0; }
+textarea { min-height: 7em; }
+
+input[type=radio], input[type=checkbox] {
+  vertical-align: baseline;
+}
+input[type=number] { text-align: right; padding-right: 0; }
+
+button, input[type=button], a.button, label.button {
+  box-shadow: .125em .125em .25em;
+  cursor: pointer;
+}
+input[type=radio], input[type=checkbox], label[for] {
+  cursor: pointer;
+}
+
+label { margin-right: .75em; }
+input + label {
+  margin-left: .375em;
+}
+
+input[type="search"] + button.search,
+input.search + button.search {
+  width: 2.5em;
+  color: transparent;
+  background-color: #CCC;
+  margin-left: -2pt;
+  border-left: none;
+  border-radius: 0 2pt 2pt 0;
+  white-space: nowrap;
+  overflow: hidden;
+}
+input[type="search"] + button.search:before,
+input.search + button.search:before {
+  content: '\1f50d';
+  color: #000;
+  font-weight: bold;
+}
+
+@media print {
+  @page { margin: 20mm; }
+
+  h1, h2, h3, h4, h5, h6, form legend {
+    page-break-inside: avoid;
+    page-break-after: avoid;
+    page-break-before: auto;
+  }
+  li { page-break-inside: avoid; }
+  th, dt { page-break-after: avoid; }
+}
+
+/* ======= End Generic Styles ======= */
+
+/* ======= Common Styles ======= */
+
+*[tooltip]:hover:after {
+  display: block;
+  position: absolute;
+  min-width: 12em;
+  bottom: 100%; left: 50%; transform: translate(-50%, 0);
+  content: attr(tooltip);
+  padding: .5em;
+  color: #000; background-color: #FFC;
+  border: .5pt solid;
+  z-index: 1;
+}
+
+input[type=radio].tab { display: none; }
+input[type=radio].tab + label {
+  display: table-cell;
+  padding: .5em 1em;
+  color: #000; background-color: #EEE;
+  border: .5pt solid;
+}
+input[type=radio].tab:checked + label {
+  background-color: #FFF;
+  border-bottom: none;
+  box-shadow: .125em -.125em .125em #888;
+  z-index: 1;
+}
+input[type=radio].tab ~ *.tab {
+  display: none;
+  width: 100%;
+  margin-top: -.5pt; padding: .25em .75em;
+  border: .5pt solid;
+  border-radius: 0;
+  box-shadow: .125em .125em .125em #888;
+}
+
+/* Markdown line-block */
+.line-block { white-space: pre-wrap; }
+.line-block br { display: none; }
+
+/* ======= End Common Styles ======= */
diff --git a/db23.sh b/db23.sh
new file mode 100755 (executable)
index 0000000..b7ab548
--- /dev/null
+++ b/db23.sh
@@ -0,0 +1,122 @@
+#!/bin/sh
+
+# Copyright 2023, 2024 Paul Hänsch
+# 
+# Permission to use, copy, modify, and/or distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+# 
+# THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+# SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
+# IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+[ -n "$include_db23" ] && return 0
+include_db23="$0"
+
+. "${_EXEC:-.}/cgilite/storage.sh"
+
+DB2() {
+  local call data file key val seq
+  data="${BR}${1}${BR}" call="$2"
+  shift 2
+
+  case $call in
+    new|discard)
+      printf ''
+      ;;
+    open|load) file="$1"
+      cat "$file" || return 1
+      ;;
+    check|contains) key="$(STRING "$1")"  seq="${2:-1}"
+      val="${data##*"${BR}${key}"      }" val="${val%%"${BR}"*}"
+      [ "$val" = '' ] && return 1 || val="${val}       "
+      while [ $seq -gt 1 ]; do
+        seq=$((seq - 1)) val="${val#*  }"
+      done
+      [ "$val" = '' ] && return 1
+      ;;
+    count) key="$(STRING "$1")" val='' seq=0
+      val="${data##*"${BR}${key}"      }" val="${val%%"${BR}"*}"
+      [ "$val" = '' ] || val="${val}   "
+      while [ "$val" != '' ]; do
+        seq=$((seq + 1)) val="${val#*  }"
+      done
+      printf "%i\n" "$seq"
+      [ $seq = 0 ] && return 1
+      ;;
+    get) key="$(STRING "$1")" seq="${2:-1}"
+      val="${data##*"${BR}${key}"      }" val="${val%%"${BR}"*}"
+      [ "$val" = '' ] && return 1 || val="${val}       "
+      while [ $seq -gt 1 ]; do
+        seq=$((seq - 1)) val="${val#*  }"
+      done
+      [ "$val" = '' ] && return 1
+      UNSTRING "${val%%        *}"
+      ;;
+    iterate|raw) key="$(STRING "$1")"
+      val="${data##*"${BR}${key}"      }" val="${val%%"${BR}"*}"
+      [ "$val" = '' ] && return 1
+      printf '%s\n' $val
+      ;;
+    delete|remove) key="$(STRING "$1")"
+      val="${data#*"${BR}${key}"       *"${BR}"}"
+      key="${data%"${BR}${key}"        *"${BR}"*}"
+      if [ "${val}" = "${data}" ]; then
+        printf %s\\n "${data}"
+        return 1
+      else
+        printf '%s' "${key#"${BR}"}${BR}${val%"${BR}"}"
+      fi
+      ;;
+    set|store) key="$(STRING "$1")" val=""
+      shift 1
+      val="$(for v in "$@"; do STRING "$v"; printf \\t; done)"
+      if [ "${data#*"${BR}${key}"      *}" != "$data" ]; then
+        data="${data%"${BR}${key}"     *"${BR}"*}${BR}${key}   ${val%  }${BR}${data#*"${BR}${key}"     *"${BR}"}"
+        data="${data#"${BR}"}" data="${data%"${BR}"}"
+      else
+        data="${data#"${BR}"}${key}    ${val%  }${BR}"
+        data="${data#"${BR}"}"
+      fi
+      printf %s\\n "${data}"
+      ;;
+    append) key="$(STRING "$1")" val=""
+      val="${data##*"${BR}${key}"      }" val="${val%%"${BR}"*}"
+      if [ "$val" = '' ]; then
+        printf %s\\n "${data}"
+        return 1
+      else
+        shift 1
+        val="${val}$(for v in "$@"; do printf \\t; STRING "$v"; done)"
+        data="${data%"${BR}${key}"     *"${BR}"*}${BR}${key}   ${val%  }${BR}${data#*"${BR}${key}"     *"${BR}"}"
+        data="${data#"${BR}"}" data="${data%"${BR}"}"
+        printf %s\\n "${data}"
+      fi
+      ;;
+    flush|save|write) file="$1"
+      data="${data#"${BR}"}" data="${data%"${BR}"}"
+      printf '%s\n' "$data" >"$file" || return 1
+      ;;
+  esac
+  return 0
+}
+
+DB3() {
+  # wrapper function that allows easyer use of DB2
+  # by always keeping file data in $db3_data
+
+  case "$1" in
+    new|discard|open|load|delete|remove|set|store|append)
+      db3_data="$(DB2 "$db3_data" "$@")"
+      return "$?"
+      ;;
+    get|count|check|contains|iterate|raw|flush|save|write)
+      DB2 "$db3_data" "$@"
+      return "$?"
+      ;;
+  esac
+}
diff --git a/file.sh b/file.sh
new file mode 100755 (executable)
index 0000000..c66b17d
--- /dev/null
+++ b/file.sh
@@ -0,0 +1,144 @@
+#!/bin/sh
+
+# Copyright 2016 - 2024 Paul Hänsch
+# 
+# Permission to use, copy, modify, and/or distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+# 
+# THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+# SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
+# IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+[ -n "$include_fileserve" ] && return 0
+include_fileserve="$0"
+
+file_type(){
+  case ${1##*.} in
+    css)       printf 'text/css';;
+    gif)       printf 'image/gif';;
+    html|html) printf 'text/html';;
+    jpg|jpeg)  printf 'image/jpeg';;
+    js)        printf 'text/javascript';;
+    m3u8)      printf 'application/x-mpegURL';;
+    m4a)       printf 'audio/mp4';;
+    m4s)       printf 'video/iso.segment';;
+    m4v|mp4)   printf 'video/mp4';;
+    mpd)       printf 'application/dash+xml';;
+    ogg)       printf 'audio/ogg';;
+    pdf)       printf 'application/pdf';;
+    png)       printf 'image/png';;
+    sh)        printf 'text/x-shellscript';;
+    svg)       printf 'image/svg+xml';;
+    tex)       printf 'text/x-tex';;
+    txt)       printf 'text/plain';;
+    short)     printf 'text/prs.shorthand';;
+    ts)        printf 'video/MP2T';;
+    webm)      printf 'video/webm';;
+    xml)       printf 'application/xml';;
+    *)         printf 'application/octet-stream';;
+  esac
+}
+
+FILE(){
+  local file="$1" mime="$2"
+  local file_size file_date http_date cachedate range
+
+  if ! [ -f "$file" ]; then
+    printf 'Content-Length: 0\r\nStatus: 404 Not Found\r\n\r\n'
+    return 0
+  elif ! [ -r "$file" ]; then
+    printf 'Content-Length: 0\r\nStatus: 403 Forbidden\r\n\r\n'
+    return 0
+  fi
+
+  read file_size file_date <<-EOF
+       $(stat -Lc "%s  %Y" "$file")
+       EOF
+  http_date="$(date -ud "@$file_date" +"%a, %d %b %Y %T GMT")"
+
+  [ ! "$HTTP_IF_MODIFIED_SINCE" -a "$cgilite_headers" ] \
+  &&    HTTP_IF_MODIFIED_SINCE="$(HEADER If-Modified-Since)"
+  [ ! "$HTTP_RANGE"             -a "$cgilite_headers" ] \
+  &&    HTTP_RANGE="$(HEADER Range)"
+
+  cachedate="$(
+    # Parse the allowable date formats from Section 3.3.1 of
+    # https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html
+    # HEADER If-Modified-Since \
+    printf %s "$HTTP_IF_MODIFIED_SINCE" \
+    | sed -E 's;^[^ ]+, ([0-9]{2}) (...) ([0-9]{4}) (..:..:..) GMT$;\3-\2-\1 \4;;
+              s;^[^ ]+, ([0-9]{2})-(...)-([789][0-9]) (..:..:..) GMT$;19\3-\2-\1 \4;;
+              s;^[^ ]+, ([0-9]{2})-(...)-([0-6][0-9]) (..:..:..) GMT$;20\3-\2-\1 \4;;
+              s;^[^ ]+ (...) ([0-9]{2}) (..:..:..) ([0-9]{4})$;\4-\1-\2 \3;;
+              s;^[^ ]+ (...)  ([0-9]) (..:..:..) ([0-9]{4})$;\4-\1-\2 \3;;
+              s;Jan;01;; s;Feb;02;; s;Mar;03;; s;Apr;04;; s;May;05;; s;Jun;06;;
+              s;Jul;07;; s;Aug;08;; s;Sep;09;; s;Oct;10;; s;Nov;11;; s;Dec;12;;' \
+    | xargs -r0 date +%s -ud 2>&-
+  )"
+
+  range="${HTTP_RANGE#bytes=}"
+  case "$range" in
+    *[!0-9]*-*|*-*[!0-9]*)
+      range=""
+      ;;
+    *-)
+      range="${range}$((file_size - 1))"
+      ;;
+    -*)
+      [ ${range#-} -le $file_size ] \
+      && range="$((file_size - ${range#-}))-$((file_size - 1))" \
+      || range="0-$((file_size - 1))"
+      ;;
+    *-*)
+      [ ${range#*-} -ge $file_size ] \
+      && range="${range%-*}-$((file_size - 1))"
+      ;;
+    *) range=""
+      ;;
+  esac
+
+  if [ "$file_date" -lt "$cachedate" ] 2>&-; then
+    printf '%s: %s\r\n' \
+      Status '304 Not Modified' \
+      Content-Length 0 \
+      Last-Modified "$http_date"
+    printf '\r\n'
+  
+  elif [ -z "$range" ]; then
+    printf '%s: %s\r\n' \
+      Status "200 OK" \
+      Accept-Ranges bytes \
+      Last-Modified "$http_date" \
+      Content-Type "${mime:-$(file_type "$file")}" \
+      Content-Length $file_size
+    printf '\r\n'
+  
+    [ "$REQUEST_METHOD" != HEAD ] && cat "$file"
+
+  elif [ "${range%-*}" -le "${range#*-}" ]; then
+    printf '%s: %s\r\n' \
+      Status "206 Partial Content" \
+      Accept-Ranges bytes \
+      Last-Modified "$http_date" \
+      Content-Type "${mime:-$(file_type "$file")}" \
+      Content-Range "bytes ${range}/${file_size}" \
+      Content-Length "$((${range#*-} - ${range%-*} + 1))"
+    printf '\r\n'
+  
+    [ "$REQUEST_METHOD" != HEAD ] \
+    && tail -c+$((${range%-*} + 1)) "$file" \
+     | head -c "$((${range#*-} - ${range%-*} + 1))"
+
+  elif [ "${range%-*}" -gt "${range#*-}" ]; then
+    printf '%s: %s\r\n' \
+      Status "216 Range Not Satisfiable" \
+      Content-Length 0 \
+      Content-Range \*/${file_size}
+    printf '\r\n'
+  fi
+}
diff --git a/html-sh.sed b/html-sh.sed
new file mode 100755 (executable)
index 0000000..1a0f2b4
--- /dev/null
@@ -0,0 +1,83 @@
+#!/bin/sed -nEf
+
+# Copyright 2018 - 2019 Paul Hänsch
+# 
+# Permission to use, copy, modify, and/or distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+# 
+# THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+# SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
+# IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+:Escapes
+s,\\\\,\&#92;,g; s,\\&,\&amp;,g;
+s,\\<,\&lt;,g; s,\\>,\&gt;,g;
+s,\\",\&quot;,g; s,\\',\&apos;,g;
+s,\\\[,\&#91;,g; s,\\\],\&#93;,g;
+s,\\\.,\&#46;,g; s,\\#,\&#35;,g;
+s,\\,,g;
+
+:CommentHandle
+x; /^<\/!-->/{
+  x; /--]/{
+    H; s;^(.*)--].*$;\1-->;p;
+    g; s;^.*--]([^\n]*)$;\1;
+    x; s;^</!-->\n(.*)\n[^\n]*$;\1;; x;
+    bCommentEnd
+  }
+  p; b;
+}
+x;
+:CommentEnd
+
+:shortcuts
+s;\[hidden[ \t]+"([^"]*)"[ \t]+"([^"]*)";[input type="hidden" name="\1" value="\2";g;
+s;\[checkbox[ \t]+"([^"]*)"[ \t]+"([^"]*)";[input type="checkbox" name="\1" value="\2";g;
+s;\[radio[ \t]+"([^"]*)"[ \t]+"([^"]*)";[input type="radio" name="\1" value="\2";g;
+s;\[submit[ \t]+"([^"]*)"[ \t]+"([^"]*)";[button type="submit" name="\1" value="\2";g;
+s;\[a[ \t]+"([^"]*)";[a href="\1";g;
+s;\[img[ \t]+"([^"]*)"[ \t]+"([^"]*)";[img src="\1" alt="\2";g;
+
+s;\[!([^]\[]*)\];<!\1>;g;
+s;\[!--([^]\[]*)--\];<!--\1-->;g;
+
+:tags
+s;\[([^]\[< \t]+)([^]\[]*)\];<\1>\2</\1>;g;
+t tags;
+
+G;
+:tagclose
+s;^([^]\n]*)\]([^\n]*)\n([^\n]+);\1\3\2;
+t tagclose;
+h; s;^([^\n]*)\n;;; x; s;\n.*$;;;
+
+:tagopen
+s;^([^\[\n]*)\[([^]\[< \t\n]+)([^\n]*);\1<\2>\3\n</\2>;
+t tagopen;
+G; h; s;^[^\n]*\n+;;; x; s;\n.*$;;;
+
+:attribs
+s;class="([^>]+)>[ \t]*\.([^< \t]+);class="\2 \1>;g; t attribs;
+s;(<[^/][^>]*)>[ \t]*\.([^< \t]+);\1 class="\2">;g;
+s;(<[^/][^>]*)>[ \t]*#([^< \t]+);\1 id="\2">;g;
+s;(<[^/][^>]*)>[ \t]*([^ \t=<]+=("[^"]*"|'[^']*'|[^< \t]*));\1 \2>;g;
+t attribs;
+s;(<input ([^>]+ )?type=(radio|"radio"|'radio')( [^>]+)?)>[ \t]*(checked|selected);\1 checked="checked">;g;
+s;(<input ([^>]+ )?type=(checkbox|"checkbox"|'checkbox')( [^>]+)?)>[ \t]*(checked|selected);\1 checked="checked">;g;
+s;(<option( [^>]+)?)>[ \t]*(checked|selected);\1 selected="selected">;g;
+s;(<select( [^>]+)?)>[ \t]*multiple;\1 multiple="multiple">;g;
+t attribs;
+s;(<[^/][^>]*>)[ \t]*\.[ \t];\1;g;
+
+s;(<[^/][^>]*>)[ \t]*;\1;g;
+# s;(<[^/][^>]*)>[ \t]*</[^>]+>;\1/>;g;
+s;(<(br|hr|img|input|link|meta|area|base|col|command|embed|keygen|param|source|track|wbr)[^>]*)>[ \t]*</\1>;\1>;g;
+
+s;<!-->;<!--;;
+
+p; ${g; s;\n+;;g; p;}
diff --git a/json.sh b/json.sh
new file mode 100755 (executable)
index 0000000..12afdc4
--- /dev/null
+++ b/json.sh
@@ -0,0 +1,360 @@
+#!/bin/sh
+
+[ -n "$include_json" ] && return 0
+include_json="$0"
+
+. "${_EXEC:-.}/cgilite/db23.sh"
+
+# debug(){ [ $# -gt 0 ] && printf '%s\n' "$@" >&2 || tee -a /dev/stderr; }
+
+json_except() {
+  printf '%s\n' "$@" >&2;
+  printf 'Exc: %s\n' "$json_document" >&2
+}
+
+json_space() {
+  while true; do case "$json_document" in
+    [" ${BR}${CR}      "]*) json_document="${json_document#?}";;
+    *) break ;;
+  esac; done
+}
+
+json_string() {
+  local string json_document="$json_document" end=0
+
+  json_space
+  case $json_document in
+    \"*) json_document="${json_document#?}"
+      ;;
+    *) json_except "Expected string specifyer starting with (\")"
+      return 1
+      ;;
+  esac
+  while [ "$json_document" ]; do case $json_document in
+    \\?*)
+      string="${string}${json_document%"${json_document#??}"}"
+      json_document="${json_document#??}"
+      ;;
+    \"*)
+      json_document="${json_document#?}"
+      end=1
+      break
+      ;;
+    *) 
+      string="${string}${json_document%"${json_document#?}"}"
+      json_document="${json_document#?}"
+      ;;
+  esac; done
+
+  if [ $end -eq 0 ]; then
+    json_except "Document ended mid-string"
+    return 1
+  fi
+
+  printf "%s   %s\n" "$(STRING "$string")" "$json_document"
+}
+
+json_key() {
+  local key json_document="$json_document"
+
+  json_space
+  case $json_document in
+    \"*)
+      key="$(json_string)" || return 1
+      json_document="${key#*   }"
+      key="${key%%     *}"
+      ;;
+    *) json_except "Expected key specifyer starting with '\"'"
+      return 1
+      ;;
+  esac
+  json_space
+  case $json_document in
+    :*) json_document="${json_document#?}"
+      ;;
+    *) json_except "Expected value separator \":\""
+      return 1
+      ;;
+  esac
+
+  printf '%s   %s\n' "$key" "$json_document"
+}
+
+json_number() {
+  local number json_document="$json_document"
+
+  json_space
+  number="${json_document%%["  ${BR}${CR} ,}]"]*}"
+  json_document="${json_document#"$number"}"
+  if ! number="$(printf %f "$number")"; then
+    json_except "Invalid number format"
+    return 1
+  fi
+
+  printf '%s   %s\n' "${number%.000000}" "$json_document"
+}
+
+json_array() {
+  local struct="$(DB2 "" new)" value json_document="$json_document"
+
+  json_space
+  case $json_document in
+    "["*) json_document="${json_document#?}"
+      ;;
+    *) json_except "Expected array starting with \"[\""
+      return 1
+      ;;
+  esac
+
+  json_space
+  case $json_document in
+    "]"*)
+      printf "%s       %s\n" "" "${json_document#?}"
+      return 0
+      ;;
+  esac
+
+  while :; do
+    json_space
+
+    value="$(json_value)" || return 1
+    json_document="${value#*   }"
+    value="$(UNSTRING "${value%%       *}")"
+
+       struct="$(DB2 "$struct" append "@" "$value")" \
+    || struct="$(DB2 "$struct" set    "@" "$value")"
+
+    json_space
+    case $json_document in
+      ,*) json_document="${json_document#?}"
+        ;;
+      "]"*) json_document="${json_document#?}"
+        break
+        ;;
+      *) json_except "Unexpected character mid-array"
+        return 1
+        ;;
+    esac
+  done
+
+  printf "%s   %s\n" "$(STRING "$struct")" "$json_document"
+}
+
+json_object() {
+  local struct="$(DB2 "" new)" key value json_document="$json_document"
+
+  json_space
+  case $json_document in
+    "{"*) json_document="${json_document#?}"
+      ;;
+    *) json_except "Expected object starting with \"{\""
+      return 1
+      ;;
+  esac
+
+  json_space
+  case $json_document in
+    "}"*)
+      printf "%s       %s\n" "" "${json_document#?}"
+      return 0
+      ;;
+  esac
+
+  while :; do
+    json_space
+
+    key="$(json_key)" || return 1
+    json_document="${key#*     }"
+    key="$(UNSTRING "${key%%   *}")"
+
+    value="$(json_value)" || return 1
+    json_document="${value#*   }"
+    value="$(UNSTRING "${value%%       *}")"
+
+    struct="$(DB2 "$struct" set "$key" "$value")"
+
+    json_space
+    case $json_document in
+      ,*) json_document="${json_document#?}"
+        ;;
+      "}"*) json_document="${json_document#?}"
+        break
+        ;;
+      *) json_except "Unexpected character mid-object"
+        return 1
+        ;;
+    esac
+  done
+
+  printf "%s   %s\n" "$(STRING "$struct")" "$json_document"
+}
+
+json_value() {
+  local value json_document="$json_document"
+  json_type=""
+
+  json_space
+  case $json_document in
+    \"*)
+      value="$(json_string)" || return 1
+      json_document="${value#* }"
+      value="str:${value%%     *}"
+      json_type=string
+      ;;
+    [+-.0-9]*)
+      value="$(json_number)" || return 1
+      json_document="${value#* }"
+      value="num:${value%%     *}"
+      json_type=number
+      ;;
+    "{"*)
+      value="$(json_object)" || return 1
+      json_document="${value#* }"
+      value="obj:${value%%     *}"
+      json_type=object
+      ;;
+    "["*)
+      value="$(json_array)" || return 1
+      json_document="${value#* }"
+      value="arr:${value%%     *}"
+      json_type=array
+      ;;
+    null*)
+      json_document="${json_document#null}"
+      value="null"
+      json_type=null
+      ;;
+    true*)
+      json_document="${json_document#true}"
+      value="true"
+      json_type=boolean
+      ;;
+    false*)
+      json_document="${json_document#false}"
+      value="false"
+      json_type=boolean
+      ;;
+  esac
+
+  printf "%s   %s\n" "$value" "$json_document"
+}
+
+json_load() {
+  local json_document="$1" json
+
+  json_value |UNSTRING
+}
+
+json_get() {
+  local json="$1" jpath="${2#.}" key idx
+  json_type=''
+
+  case $json in
+    str:*) json_type="string";;
+    arr:*) json_type="array";;
+    obj:*) json_type="object";;
+    num:*) json_type="number";;
+    true|false)
+           json_type="boolean";;
+    null)  json_type="null";;
+  esac
+
+  case $jpath in
+    "")
+      printf %s\\n "${json#???:}"
+      return 0
+      ;;
+    "["[0-9]*"]"*)
+      idx="${jpath%%"]"*}" idx="${idx#"["}"
+      jpath="${jpath#"["*"]"}"
+      ;;
+    "['"*"']"*)
+      key="${jpath%%"']"*}" key="${key#"['"}"
+      jpath="${jpath#"['"*"']"}"
+      ;;
+    "$"*)
+      jpath="${jpath#?}"
+      ;;
+    *) key="${jpath%%[".["]*}"
+      jpath="${jpath#"$key"}"
+      ;;
+  esac
+
+  if   [ "$key" -a "$json_type" = object ]; then
+    if ! json="$(DB2 "${json#obj:}" get "$key")"; then
+      debug "Key not found: \"$key\""
+      return 1
+    fi
+  elif [ "$idx" -a "$json_type" = array ]; then
+    if ! json="$(DB2 "${json#arr:}" get @ "$(( idx + 1 ))")"; then
+      debug "Array index not found: \"$idx\""
+      return 1
+    fi
+  elif [ "$key" ]; then
+    debug "Cannot select key (\"$key\") from value of type \"$json_type\""
+    return 1
+  elif [ "$idx" ]; then
+    debug "Cannot select index ($idx) from value of type \"$json_type\""
+    return 1
+  fi
+  json_get "$json" "$jpath"
+  return $?
+}
+
+json_dump_string() {
+  local in="$1" out=''
+  while [ "$in" ]; do case $in in
+    \\*) out="${out}\\\\"; in="${in#\\}" ;;
+    "$BR"*) out="${out}\\n"; in="${in#${BR}}" ;;
+    "$CR"*) out="${out}\\r"; in="${in#${CR}}" ;;
+    "   "*) out="${out}\\t"; in="${in#  }" ;;
+    \"*) out="${out}\\\""; in="${in#\"}" ;;
+    *) out="${out}${in%%[\\${CR}${BR}  \"]*}"; in="${in#"${in%%[\\${BR}${CR}   \"]*}"}" ;;
+  esac; done
+  printf '"%s"' "${out}"
+}
+
+json_dump_array() {
+  local json="$1" value out=''
+
+  for value in $(DB2 "$json" iterate @); do
+    out="${out},$(json_dump "$(UNSTRING "$value")")"
+  done
+  printf '[%s]' "${out#,}"
+}
+
+json_dump_object() {
+  local json="$1" key value out=''
+
+  while read -r key value; do
+    out="${out},$(json_dump_string "$(UNSTRING "$key")"):$(json_dump "$(UNSTRING "$value")")"
+  done <<-EOF
+       ${json}
+       EOF
+  printf '{%s}' "${out#,}"
+}
+
+json_dump() {
+  local json="$1"
+
+  case $json in
+    str:*)
+      json_dump_string "${json#str:}"
+      ;;
+    arr:*)
+      json_dump_array "${json#arr:}"
+      ;;
+    obj:*)
+      json_dump_object "${json#obj:}"
+      ;;
+    num:*)
+      printf "${json#num:}"
+      ;;
+    true|false|null)
+      printf %s\\n "$json"
+      ;;
+    *)
+      json_dump_string "${json}"
+      ;;
+  esac
+}
diff --git a/logging.sh b/logging.sh
new file mode 100755 (executable)
index 0000000..31bb24d
--- /dev/null
@@ -0,0 +1,32 @@
+#!/bin/sh
+
+# LOGLEVEL 1: Crash condition
+# LOGLEVEL 2: Unexpected condition
+# LOGLEVEL 3: Failed action (i.e. due to config error)
+# LOGLEVEL 4: Debug
+
+[ -n "$include_logging" ] && return 0
+include_logging="$0"
+
+LOGLEVEL="${LOGLEVEL:-3}"
+LOGFILE="${LOGFILE:-/dev/stderr}"
+
+logmsg(){
+  local ll="${1:-3}"
+  shift 1
+  if [ "$ll" -le "$LOGLEVEL" -a "$#" -gt 0 ]; then
+    printf %s\\n "$*" >>"${LOGFILE}"
+  elif [ "$ll" -le "$LOGLEVEL" ]; then
+    tee -a "${LOGFILE}"
+  elif [ ! "$#" -gt 0 ]; then
+    cat
+  fi
+}
+
+die(){
+  [ "$#" -gt 0 ] && logmsg 1 "$@"
+  exit 1
+}
+panic(){ logmsg 2 "$@"; }
+error(){ logmsg 3 "$@"; }
+debug(){ logmsg 4 "$@"; }
diff --git a/markdown.awk b/markdown.awk
new file mode 100755 (executable)
index 0000000..744bcba
--- /dev/null
@@ -0,0 +1,1003 @@
+#!/bin/awk -f
+#!/opt/busybox/awk -f
+
+# EXPERIMENTAL Markdown processor with minimal dependencies.
+# Meant to support all features of John Grubers basic Markdown
+# + a number of common extensions, mostly inspired by Pandoc Markdown
+
+# Copyright 2021 - 2024 Paul Hänsch
+# 
+# Permission to use, copy, modify, and/or distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+# 
+# THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+# SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
+# IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+# Supported Features / TODO:
+# ==========================
+# [x] done    [ ] todo    [-] not planned    ? unsure
+#
+# Basic Markdown - Block elements:
+# -------------------------------
+# - [x] Paragraphs
+#   - [x] Double space line breaks
+# - [x] Proper block element nesting
+# - [x] Headings
+# - [x] ATX-Style Headings
+# - [x] Blockquotes
+# - [x] Lists (ordered, unordered)
+# - [x] Code blocks (using indention)
+# - [x] Horizontal rules
+# - [x] Verbatim HTML block (disabled by default)
+#
+# Basic Markdown - Inline elements:
+# ---------------------------------
+# - [x] Links
+# - [x] Reference style links
+# - [x] Emphasis *em*/**strong** (*Asterisk*, _Underscore_)
+# - [x] `code`, also ``code containing `backticks` ``
+# - [x] Images / reference style images
+# - [x] <automatic links>
+# - [x] backslash escapes
+# - [x] Verbatim HTML inline (disabled by default)
+# - [x] HTML escaping
+#
+# NOTE: Set the environment variable MD_HTML=true to enable verbatim HTML
+#
+# Extensions - Block elements:
+# ----------------------------
+# - [x] Automatic <section>-wrapping (custom)
+# -  ?  Heading identifiers (php md, pandoc)
+#   - [x] Heading attributes (custom)
+#   - [ ] <hr> terminates section
+# - [x] Automatic heading identifiers (custom)
+# - [x] Fenced code blocks (php md, pandoc)
+#   - [x] Fenced code attributes
+# - [x] Images (as block elements, <figure>-wrapped) (custom)
+#   - [x] reference style block images
+# - [/] Tables
+#   -  ?  Simple table (pandoc)
+#   -  ?  Multiline table (pandoc)
+#   - [x] Grid table (pandoc)
+#     - [x] Headerless
+#   - [x] Pipe table (php md, pandoc)
+# - [x] Line blocks (pandoc)
+# - [x] Task lists (pandoc, custom)
+# - [x] Definition lists (php md, pandoc)
+# - [-] Numbered example lists (pandoc)
+# - [-] Metadata blocks (pandoc)
+# - [x] Metadata blocks (custom)
+# - [x] Fenced Divs (pandoc)
+#
+# Extensions - Inline elements:
+# ----------------------------
+# - [x] Ignore embedded_underscores (php md, pandoc)
+# - [x] ~~strikeout~~ (pandoc)
+# - [x] ^Superscript^ ~Subscript~ (pandoc)
+# - [-] Bracketed spans (pandoc)
+#   - [-] Inline attributes (pandoc)
+# - [x] Image attributes (custom, pandoc inspired, not for reference style)
+# - [x] Wiki style links [[PageName]] / [[PageName|Link Text]]
+# - [-] TEX-Math (pandoc)
+# -  ?  Footnotes (php md)
+# -  ?  Abbreviations (php md)
+# -  ?  "Curly quotes" (smartypants)
+# - [ ] em-dashes (--) (smartypants old)
+# -  ?  ... three-dot ellipsis (smartypants)
+# - [-] en-dash (smartypants)
+# - [ ] Automatic em-dash / en-dash
+# - [x] Automatic -> Arrows <- (custom)
+
+function debug(text) { printf "\n---\n%s\n---\n", text > "/dev/stderr"; }
+
+function HTML ( text ) {
+  gsub( /&/,  "\\&amp;",  text );
+  gsub( /</,  "\\&lt;",   text );
+  gsub( />/,  "\\&gt;",   text );
+  gsub( /"/,  "\\&quot;", text );
+  gsub( /'/,  "\\&#x27;", text );
+  gsub( /\\/, "\\&#x5C;", text );
+  return text;
+}
+
+function URL ( text, sharp ) {
+  gsub( /&/,  "%26",  text );
+  gsub( /"/,  "%22", text );
+  gsub( /'/,  "%27", text );
+  gsub( /`/,  "%60", text );
+  gsub( /\?/,  "%3F", text );
+  if (sharp) gsub( /#/,  "%23", text );
+  gsub( /\[/,  "%5B", text );
+  gsub( /\]/,  "%5D", text );
+  gsub( / /,  "%20", text );
+  gsub( /      /,  "%09", text );
+  gsub( /\\/, "%5C", text );
+  return text;
+}
+
+function inline( line, LOCAL, len, text, code, href, guard, ret ) {
+  ret = "";
+  while (line !~ /^$/) {
+    # omit processing of escaped characters
+    if ( line ~ /^\\./) {
+      ret = ret HTML(substr(line, 2, 1)); line = substr(line, 3);
+      continue;
+
+    # hard brakes
+    } else if ( match(line, /^  \n/) ) {
+      ret = ret "<br>\n"; line = substr(line, RLENGTH + 1);
+      continue;
+
+    #  ``code spans``
+    } else if ( match( line, /^`+/) ) {
+      len = RLENGTH
+      guard = substr( line, 1, len )
+      if ( match(line, guard ".*" guard) ) {
+        code = substr( line, len + 1, match( substr(line, len + 1), guard ) - 1)
+        len = 2 * length(guard) + length(code)
+        #  strip single surrounding white spaces
+        gsub( /^ | $/, "", code)
+        #  escape HTML within code span
+        gsub( /&/, "\\&amp;", code ); gsub( /</, "\\&lt;", code ); gsub( />/, "\\&gt;", code );
+        ret = ret "<code>" code "</code>"; line = substr( line, len + 1 );
+        continue;
+      }
+
+    # Macros
+    } else if ( match( line, /^<<([^>]|>[^>])+>>/ ) ) {
+      len = RLENGTH;
+      ret = ret "<code class=\"macro\">" HTML( substr( line, 3, len - 4 ) ) "</code>"; line = substr(line, len + 1);
+      continue;
+
+    # Wiki style links
+    } else if ( match( line, /^\[\[([^]|]+)(\|[^]]+)?\]\]/) ) {
+      len = RLENGTH; href = text = substr(line, 1, len);
+      sub(/^\[\[/, "", href); sub(/(\|([^]]+))?\]\].*$/, "", href);
+      sub(/^\[\[([^]|]+)/, "", text); sub(/\]\].*$/, "", text); sub(/^\|/, "", text);
+      # sub(/^\[\[([^]|]+)(\|([^]]+))?\]\]/, "\\1", href );
+      # sub(/^\[\[([^]|]+)(\|([^]]+))?\]\]/, "\\3", text );
+      if ( ! text ) text = href;
+      ret = ret "<a href=\"" HTML(href) "\">" HTML(text) "</a>"; line = substr( line, len + 1);
+      continue;
+
+    #  quick links ("automatic links" in md doc)
+    } else if ( match( line, /^<[a-zA-Z]+:\/\/([-\.[:alnum:]]+)(:[0-9]*)?(\/[^>]*)?>/ ) ) {
+      len = RLENGTH;
+      href = HTML( substr( line, 2, len - 2) );
+      ret = ret "<a href=\"" href "\">" href "</a>"; line = substr( line, len + 1);
+      continue;
+
+    # quick link email
+    # } else if ( match( line, /^<[a-zA-Z0-9.!#$%&'\''*+\/=?^_`{|}~-]+@[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*>/ ) ) {
+    } else if ( match( line, /^<[a-zA-Z0-9.!#$%&'\''*+\/=?^_`{|}~-]+@([a-zA-Z0-9]\.[a-zA-Z0-9]|[a-zA-Z0-9-])+>/ ) ) {
+      len = RLENGTH;
+      href = HTML( substr( line, 2, len - 2) );
+      ret = ret "<a href=\"mailto:" href "\">" href "</a>"; line = substr( line, len + 1);
+      continue;
+
+    # Verbatim inline HTML
+    } else if ( AllowHTML && match( line, /^(<!--([^-]|-[^-]|--[^>])*-->|<\?([^\?]|\?[^>])*\?>|<![A-Z][^>]*>|<!\[CDATA\[([^\]]|\][^\]]|\]\][^>])*\]\]>|<\/[A-Za-z][A-Za-z0-9-]*[[:space:]]*>|<[A-Za-z][A-Za-z0-9-]*([[:space:]]+[A-Za-z_:][A-Za-z0-9_\.:-]*([[:space:]]*=[[:space:]]*([[:space:]"'=<>`]+|"[^"]*"|'[^']*'))?)*[[:space:]]*\/?>)/) ) {
+      len = RLENGTH;
+      ret = ret substr( line, 1, len); line =substr(line, len + 1);
+      continue;
+
+    # inline links
+    } else if ( match(line, "^" lii "\\([\n\t ]*" lid "([\n\t ]+" lit ")?[\n\t ]*\\)") ) {
+      len = RLENGTH;
+      text = href = title = substr( line, 1, len);
+      sub("^\\[", "", text); sub("\\]\\([\n\t ]*" lid "([\n\t ]+" lit ")?[\n\t ]*\\)$", "", text);
+      sub("^" lii "\\([\n\t ]*", "", href); sub("([\n\t ]+" lit ")?[\n\t ]*\\)$", "", href);
+      sub("^" lii "\\([\n\t ]*" lid, "", title); sub("[\n\t ]*\\)$", "", title); sub("^[\n\t ]+", "", title);
+
+      if ( match(href, /^<.*>$/) ) { sub(/^</, "", href); sub(/>$/, "", href); }
+           if ( match(title, /^".*"$/) ) { sub(/^"/, "", title); sub(/"$/, "", title); }
+      else if ( match(title, /^'.*'$/) ) { sub(/^'/, "", title); sub(/'$/, "", title); }
+      else if ( match(title, /^\(.*\)$/) ) { sub(/^\(/, "", title); sub(/\)$/, "", title); }
+
+      gsub(/\\/, "", href); gsub(/\\/, "", title); gsub(/[\n\t]+/, " ", title);
+
+      ret = ret "<a href=\"" HTML(href) "\"" (title?" title=\"" HTML(title) "\"":"") ">" \
+             inline( text ) "</a>";
+      line = substr( line, len + 1);
+      continue;
+
+    # reference style links
+    } else if ( match(line, /^\[([^]]+)\] ?\[([^]]*)\]/ ) ) {
+      len = RLENGTH; text = id = substr(line, 1, len);
+      sub(/\n.*$/, "", text); sub(/^\[/, "", text); sub(/\] ?\[([^\n]*)\].*$/, "", text);
+      sub(/\n.*$/, "",   id); sub(/^\[([^]]+)\] ?\[/, "",   id); sub(/\].*$/, "",   id);
+      # text = gensub(/^\[([^\n]+)\] ?\[([^\n]*)\].*/, "\\1", 1, text );
+      # id = gensub(/^\[([^\n]+)\] ?\[([^\n]*)\].*/, "\\2", 1,   id );
+      if ( ! id ) id = text;
+
+      if ( rl_href[id] && rl_title[id] ) {
+        ret = ret "<a href=\"" HTML(rl_href[id]) "\" title=\"" HTML(rl_title[id]) "\">" inline(text) "</a>";
+        line = substr( line, len + 1);
+        continue;
+
+      } else if ( rl_href[id] ) {
+        ret = ret "<a href=\"" HTML(rl_href[id]) "\">" inline(text) "</a>"; line = substr( line, len + 1);
+        continue;
+
+      } else {
+        ret = ret "" HTML(substr(line, 1, len)); line = substr(line, len + 1);
+        continue;
+      }
+
+    # inline images
+    } else if ( match(line, "^!" lix "\\([\n\t ]*" lid "([\n\t ]+" lit ")?[\n\t ]*\\)(\\{[a-zA-Z \t-]*\\})?") ) {
+      len = RLENGTH; text = href = title = attrib = substr( line, 1, len);
+
+      sub("^!\\[", "", text);
+      sub("\\]\\([\n\t ]*" lid "([\n\t ]+" lit ")?[\n\t ]*\\)(\\{[a-zA-Z \t-]*\\})?$", "", text);
+
+      sub("^!" lix "\\([\n\t ]*", "", href);
+      sub("([\n\t ]+" lit ")?[\n\t ]*\\)(\\{[a-zA-Z \t-]*\\})?$", "", href);
+
+      sub("^!" lix "\\([\n\t ]*" lid, "", title);
+      sub("[\n\t ]*\\)(\\{[a-zA-Z \t-]*\\})?$", "", title);
+      sub("^[\n\t ]+", "", title);
+
+      sub("^!" lix "\\([\n\t ]*" lid "([\n\t ]+" lit ")?[\n\t ]*\\)", "", attrib);
+      sub(/^\{[ \t]*/, "", attrib); sub(/[ \t]*\}$/, "", attrib); gsub(/[ \t]+/, " ", attrib);
+
+      if ( match(href, /^<.*>$/) ) { sub(/^</, "", href); sub(/>$/, "", href); }
+           if ( match(title, /^".*"$/) ) { sub(/^"/, "", title); sub(/"$/, "", title); }
+      else if ( match(title, /^'.*'$/) ) { sub(/^'/, "", title); sub(/'$/, "", title); }
+      else if ( match(title, /^\(.*\)$/) ) { sub(/^\(/, "", title); sub(/\)$/, "", title); }
+
+      gsub(/^[\t ]+$/, "", text); gsub(/\\/, "", href);
+      gsub(/\\/, "", title); gsub(/[\n\t]+/, " ", title);
+
+      ret = ret "<img src=\"" HTML(href) "\" alt=\"" HTML(text?text:title?title:href) "\"" \
+             (title?" title=\"" HTML(title) "\"":"") (attrib?" class=\"" HTML(attrib) "\"":"") \
+             ">";
+      line = substr( line, len + 1);
+      continue;
+
+    # reference style images
+    } else if ( match(line, /^!\[([^]]*)\] ?\[([^]]*)\]/ ) ) {
+      len = RLENGTH; text = id = substr(line, 1, len);
+      sub(/\n.*$/, "", text); sub(/^!\[/, "", text); sub(/\] ?\[([^\n]*)\].*$/, "", text);
+      sub(/\n.*$/, "",   id); sub(/^!\[([^]]+)\] ?\[/, "",   id); sub(/\].*$/, "",   id);
+      # text = gensub(/^!\[([^\n]*)\] ?\[([^\n]*)\].*/, "\\1", 1, substr(line, 1, len) );
+      #   id = gensub(/^!\[([^\n]*)\] ?\[([^\n]*)\].*/, "\\2", 1, substr(line, 1, len) );
+      if ( ! id ) id = text;
+      if ( rl_href[id] && rl_title[id] ) {
+        ret = ret "<img src=\"" HTML(rl_href[id]) "\" alt=\"" HTML(text) "\" title=\"" HTML(rl_title[id]) "\">";
+        line = substr( line, len + 1);
+        continue;
+
+      } else if ( rl_href[id] ) {
+        ret = ret "<img src=\"" HTML(rl_href[id]) "\" alt=\"" HTML(text) "\">";
+        line = substr( line, len + 1);
+        continue;
+
+      } else {
+        ret = ret "" HTML(substr(line, 1, len)); line = substr(line, len + 1);
+        continue;
+      }
+
+    #  ~~strikeout~~ (pandoc)
+    } else if ( match(line, /^~~([[:graph:]]|[[:graph:]]([^~]|~[^~])*[[:graph:]])~~/) ) {
+      len = RLENGTH;
+      ret = ret "<del>" inline( substr( line, 3, len - 4 ) ) "</del>"; line = substr( line, len + 1 );
+      continue;
+
+    #  ^superscript^ (pandoc)
+    } else if ( match(line, /^\^([^[:space:]^]|\\[ ^])+\^/) ) {
+      len = RLENGTH;
+      ret = ret "<sup>" inline( substr( line, 2, len - 2 ) ) "</sup>"; line = substr( line, len + 1 );
+      continue;
+
+    #  ~subscript~ (pandoc)
+    } else if ( match(line, /^~([^[:space:]~]|\\[ ~])+~/) ) {
+      len = RLENGTH;
+      ret = ret "<sub>" inline( substr( line, 2, len - 2 ) ) "</sub>"; line = substr( line, len + 1 );
+      continue;
+
+    # ignore embedded underscores (pandoc, php md)
+    } else if ( match(line, "^[[:alnum:]](__|_)") ) {
+      ret = ret HTML(substr( line, 1, RLENGTH)); line = substr(line, RLENGTH + 1);
+      continue;
+
+    # strong / em matchers use pre match pattern to make processing cheaper
+    #  __strong__$
+    } else if ( match(line, "^__(([^_[:space:]]|" ieu ")|([^_[:space:]]|" ieu ")(" nu "|" ieu ")*([^_[:space:]]|" ieu "))__$") ) {
+      len = RLENGTH;
+      ret = ret "<strong>" inline( substr( line, 3, len - 4 ) ) "</strong>"; line = substr( line, len + 1 );
+      continue;
+
+    #  __strong__
+    } else if ( match(line, "^__(([^_[:space:]]|" ieu ")|([^_[:space:]]|" ieu ")(" nu "|" ieu ")*([^_[:space:]]|" ieu "))__[[:space:][:punct:]]") ) {
+      len = RLENGTH;
+      ret = ret "<strong>" inline( substr( line, 3, len - 5 ) ) "</strong>"; line = substr( line, len);
+      continue;
+
+    #  **strong**
+    } else if ( match(line, "^\\*\\*(([^*[:space:]]|" iea ")|([^*[:space:]]|" iea ")(" na "|" iea ")*([^*[:space:]]|" iea "))\\*\\*") ) {
+      len = RLENGTH;
+      ret = ret "<strong>" inline( substr( line, 3, len - 4 ) ) "</strong>"; line = substr( line, len + 1 );
+      continue;
+
+    #  _em_$
+    } else if ( match(line, "^_(([^_[:space:]]|" isu ")|([^_[:space:]]|" isu ")(" nu "|" isu ")*([^_[:space:]]|" isu "))_$") ) {
+      len = RLENGTH;
+      ret = ret "<em>" inline( substr( line, 2, len - 2 ) ) "</em>"; line = substr( line, len + 1 );
+      continue;
+
+    #  _em_
+    } else if ( match(line, "^_(([^_[:space:]]|" isu ")|([^_[:space:]]|" isu ")(" nu "|" isu ")*([^_[:space:]]|" isu "))_[[:space:][:punct:]]") ) {
+      len = RLENGTH;
+      ret = ret "<em>" inline( substr( line, 2, len - 3 ) ) "</em>"; line = substr( line, len );
+      continue;
+
+    #  *em*
+    } else if ( match(line, "^\\*(([^*[:space:]]|" isa ")|([^*[:space:]]|" isa ")(" na "|" isa ")*([^*[:space:]]|" isa "))\\*") ) {
+      len = RLENGTH;
+      ret = ret "<em>" inline( substr( line, 2, len - 2 ) ) "</em>"; line = substr( line, len + 1 );
+      continue;
+
+    # Literal HTML entities
+    # } else if ( match( line, /^&([a-zA-Z]{2,32}|#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6});/) ) {
+    # mawk does not support repitition ranges
+    } else if ( match( line, /^&[a-zA-Z][a-zA-Z][a-zA-Z]?[a-zA-Z]?[a-zA-Z]?[a-zA-Z]?[a-zA-Z]?[a-zA-Z]?[a-zA-Z]?[a-zA-Z]?[a-zA-Z]?[a-zA-Z]?[a-zA-Z]?[a-zA-Z]?[a-zA-Z]?[a-zA-Z]?[a-zA-Z]?[a-zA-Z]?[a-zA-Z]?[a-zA-Z]?[a-zA-Z]?[a-zA-Z]?[a-zA-Z]?[a-zA-Z]?[a-zA-Z]?[a-zA-Z]?[a-zA-Z]?[a-zA-Z]?[a-zA-Z]?[a-zA-Z]?[a-zA-Z]?[a-zA-Z]?;/) ) {
+      len = RLENGTH;
+      ret = ret substr( line, 1, len ); line = substr(line, len + 1);
+      continue;
+    } else if ( match( line, /^&(#[0-9][0-9]?[0-9]?[0-9]?[0-9]?[0-9]?[0-9]?|#[xX][0-9a-fA-F][0-9a-fA-F]?[0-9a-fA-F]?[0-9a-fA-F]?[0-9a-fA-F]?[0-9a-fA-F]?);/) ) {
+      len = RLENGTH;
+      ret = ret substr( line, 1, len ); line = substr(line, len + 1);
+      continue;
+
+    # Arrows
+    } else if ( line ~ /^-->( |$)/) {  # ignore multidash-arrow
+      ret = ret "--&gt;"; line = substr(line, 4);
+      continue;
+    } else if ( line ~ /^<->( |$)/) {
+      ret = ret "&harr;"; line = substr(line, 4);
+      continue;
+    } else if ( line ~ /^<-( |$)/) {
+      ret = ret "&larr;"; line = substr(line, 3);
+      continue;
+    } else if ( line ~ /^->( |$)/) {
+      ret = ret "&rarr;"; line = substr(line, 3);
+      continue;
+
+    # Escape lone HTML character
+    } else if ( match( line, /^[&<>"']/) ) {
+      ret = ret HTML(substr(line, 1, 1)); line = substr(line, 2);
+      continue;
+
+    }  # inline patterns end
+
+    # continue walk over string
+    ret = ret substr(line, 1, 1); line = substr(line, 2);
+  }
+  return ret;
+}
+
+function headline( hlvl, htxt, attrib, LOCAL, sec, n, HL) {
+  # match(hstack, /([0-9]+( [0-9]+){5})$/); split( substr(hstack, RSTART),  HL);
+  match(hstack, /([0-9]+( [0-9]+)( [0-9]+)( [0-9]+)( [0-9]+)( [0-9]+))$/); split( substr(hstack, RSTART),  HL);
+
+  for ( n = hlvl; n <= 6; n++ ) { sec = sec (HL[n]?"</section>":""); }
+  HL[hlvl]++; for ( n = hlvl + 1; n <= 6; n++) { HL[n] = 0;}
+
+  hid = ""; for ( n = 2; n <= blvl; n++) { hid = hid BL[n] "/"; }
+  hid = hid HL[1]; for ( n = 2; n <= hlvl; n++) { hid = hid "." HL[n] ; }
+  hid = hid ":" URL(htxt, 1);
+
+  # sub(/([0-9]+( [0-9]+){5})$/, "", hstack);
+  sub(/([0-9]+( [0-9]+)( [0-9]+)( [0-9]+)( [0-9]+)( [0-9]+))$/, "", hstack);
+  hstack = hstack HL[1] " " HL[2] " " HL[3] " " HL[4] " " HL[5] " " HL[6];
+
+  return sec "<section class=\"" (attrib ? "h" hlvl " " attrib : "h" hlvl)  "\" id=\"" hid "\">" \
+         "<h" hlvl (attrib ? " class=\"" attrib "\"" : "") ">" inline( htxt ) \
+         "<a class=\"anchor\" href=\"#" hid "\"></a>" \
+         "</h" hlvl ">\n";
+}
+
+# Nested Block, resets heading counters
+function _nblock( block, LOCAL, sec, n ) {
+  hstack = hstack " 0 0 0 0 0 0";
+
+  # Block Level
+  blvl++; BL[blvl]++;
+  for ( n = blvl + 1; n in BL; n++) { delete BL[n]; }
+
+  block = _block( block );
+  match(hstack, /([0-9]+( [0-9]+)( [0-9]+)?( [0-9]+)?( [0-9]+)?( [0-9]+)?)$/); split( substr(hstack, RSTART),  HL);
+  sec = ""; for ( n = 1; n <= 6; n++ ) { sec = sec (HL[n]?"</section>":""); }
+
+  sub("( +[0-9]+)( +[0-9]+)?( +[0-9]+)?( +[0-9]+)?( +[0-9]+)?( +[0-9]+)? *$", "", hstack); blvl--;
+  return block sec;
+}
+
+function _block( block, LOCAL, st, len, text, title, attrib, href, guard, code, indent, list, tmp, ret) {
+  ret = "";
+  while ( block != "" ) {
+    gsub( "(^\n+|\n+$)", "", block );
+
+    # HTML #2 #3 #4 $5
+    if ( AllowHTML && match( block, /(^|\n) ? ? ?(<!--([^-]|-[^-]|--[^>])*(-->|$)|<\?([^\?]|\?[^>])*(\?>|$)|<![A-Z][^>]*(>|$)|<!\[CDATA\[([^\]]|\][^\]]|\]\][^>])*(\]\]>|$))/) ) {
+      len = RLENGTH; st = RSTART;
+      ret = ret _block(substr(block, 1, st - 1)) substr(block, st, len); block = substr(block, st + len);
+      continue;
+
+    # HTML #6 (part1)
+    } else if ( AllowHTML && match( tolower(block), /(^|\n) ? ? ?<\/?(address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset)([[:space:]\n>]|\/>)([^\n]|\n[ \t]*[^\n])*(\n[[:space:]]*\n|$)/) ) {
+      len = RLENGTH; st = RSTART;
+      ret = ret _block(substr(block, 1, st - 1)) substr(block, st, len); block = substr(block, st + len);
+      continue;
+
+    # HTML #6 (part2)
+    } else if ( AllowHTML && match( tolower(block), /(^|\n) ? ? ?<\/?(h[123456]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|section|source|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul)([[:space:]\n>]|\/>)([^\n]|\n[ \t]*[^\n])*(\n[[:space:]]*\n|$)/) ) {
+      len = RLENGTH; st = RSTART;
+      ret = ret _block(substr(block, 1, st - 1)) substr(block, st, len); block = substr(block, st + len);
+      continue;
+
+    # HTML #1
+    } else if ( AllowHTML && match( tolower(block), /(^|\n) ? ? ?<(script|pre|style)([[:space:]\n>]).*(<\/script>|<\/pre>|<\/style>|$)/) ) {
+      len = RLENGTH; st = RSTART;
+      match( tolower(substr(block, st, len)), /(<\/script>|<\/pre>|<\/style>)/);
+      len = RSTART + RLENGTH;
+      ret = ret _block(substr(block, 1, st - 1)) substr(block, st, len); block = substr(block, st + len);
+      continue;
+
+    # HTML #7
+    } else if ( AllowHTML && match( block, /^ ? ? ?(<\/[A-Za-z][A-Za-z0-9-]*[[:space:]]*>|<[A-Za-z][A-Za-z0-9-]*([[:space:]]+[A-Za-z_:][A-Za-z0-9_\.:-]*([[:space:]]*=[[:space:]]*([[:space:]"'=<>`]+|"[^"]*"|'[^']*'))?)*[[:space:]]*\/?>)([[:space:]]*\n)([^\n]|\n[ \t]*[^\n])*(\n[[:space:]]*\n|$)/) ) {
+      len = RLENGTH; st = RSTART;
+      ret = ret substr(block, st, len); block = substr(block, st + len);
+      continue;
+
+    # Metadata (custom, block starting with %something)
+    # Metadata is ignored but can be interpreted externally
+    } else if ( match(block, /^%[a-zA-Z-]+([[:space:]][^\n]*)?(\n|$)(%[a-zA-Z-]+([[:space:]][^\n]*)?(\n|$)|%([[:space:]][^\n]*)?(\n|$)|[ \t]+[^\n[:space:]][^\n]*(\n|$))*/) ) {
+      len = RLENGTH; st = RSTART;
+      block = substr( block, len + 1);
+      continue;
+    # Blockquote (leading >)
+    } else if ( match( block, /^> /) ) {
+      match( block, /(^|\n)[[:space:]]*(\n|$)/ ) || match(block, /$/);
+      len = RLENGTH; st = RSTART;
+      text = substr(block, 1, st - 1); gsub( /(^|\n)> /, "\n", text );
+      text = _nblock( text ); gsub( /^\n|\n$/, "", text )
+      ret = ret "<blockquote>" text "</blockquote>\n\n"; block = substr(block, st + len);
+      continue;
+
+    # Pipe Tables (pandoc / php md / gfm )
+    } else if ( match(block, "^((\\|)?([^\n]+\\|)+[^\n]+(\\|)?)\n" \
+                             "((\\|)?(:?-+:?[\\|+])+:?-+:?(\\|)?)\n" \
+                             "((\\|)?([^\n]+\\|)+[^\n]+(\\|)?(\n|$))+" ) ) {
+      len = RLENGTH; st = RSTART;
+      #initialize empty arrays
+      split("", talign); split("", tarray);
+      cols = 0; cnt=0; ttext = "";
+
+      # table header and alignment
+      tmp = substr(block, 1, match(block, /(\n|$)/));
+      gsub( /(^|[^\\])\\\|/, "\\1\\&#x7C;", tmp );
+      gsub( /(^\||\|$)/, "", tmp)
+      split( tmp, tarray, /\|/);
+      block = substr(block, match(block, /(\n|$)/) + 1 );
+      tmp = substr(block, 1, match(block, /(\n|$)/));
+      gsub( /(^\||\|$)/, "", tmp );
+      cols = split( tmp , talign, /[+\|]/);
+      block = substr(block, match(block, /(\n|$)/) + 1 );
+
+      for( cnt = 1; cnt < cols; cnt++ ) {
+             if (match(talign[cnt], /:-+:/)) talign[cnt]="center";
+        else if (match(talign[cnt],  /-+:/)) talign[cnt]="right";
+        else if (match(talign[cnt],  /:-+/)) talign[cnt]="left";
+        else talign[cnt]="";
+      }
+
+      ttext = "<thead>\n<tr>"
+      for (cnt = 1; cnt < cols; cnt++)
+        ttext = ttext "<th align=\"" talign[cnt] "\">" inline(tarray[cnt]) "</th>"
+      ttext = ttext "</tr>\n</thead><tbody>\n"
+
+      while ( match(block, "^((\\|)?([^\n]+\\|)+[^\n]+(\\|)?(\n|$))+" ) ){
+        tmp = substr(block, 1, match(block, /(\n|$)/));
+        gsub( /(^|[^\\])\\\|/, "\\1\\&#x7C;", tmp );
+        gsub( /(^\||\|$)/, "", tmp );
+        split( tmp, tarray, /\|/);
+        block = substr(block, match(block, /(\n|$)/) + 1 );
+
+        ttext = ttext "<tr>"
+        for (cnt = 1; cnt < cols; cnt++)
+          ttext = ttext "<td align=\"" talign[cnt] "\">" inline(tarray[cnt]) "</td>"
+        ttext = ttext "</tr>\n"
+      }
+      ret = ret "<table>" ttext "</tbody></table>\n";
+      continue;
+
+    # Grid Tables (pandoc)
+    # (with, and without header)
+    } else if ( match( block, "^\\+(-+\\+)+\n" \
+                              "(\\|([^\n]+\\|)+\n)+" \
+                              "(\\+(:?=+:?\\+)+)\n" \
+                             "((\\|([^\n]+\\|)+\n)+" \
+                               "\\+(-+\\+)+(\n|$))+" \
+                     ) || \
+                match( block, "^(\\+(:?-+:?\\+)+)\n" \
+                             "((\\|([^\n]+\\|)+\n)+" \
+                               "\\+(-+\\+)+(\n|$))+" \
+    ) ) {
+      len = RLENGTH; st = RSTART;
+      #initialize empty arrays
+      split("", talign); split("", tarray); split("", tread);
+      cols = 0; cnt=0; ttext = "";
+
+      # Column Count
+      tmp = block; sub( "(\n.*)*$", "", tmp);
+      cols = split( tmp, tread, /\+/) - 2;
+      # debug(" Cols: " gensub( "^(\\+(:?-+:?\\+)+)(\n.*)*$", "\\1", 1, block ));
+
+      # table alignment
+      match(block, "((:?=+:?\\+|(:-+|-+:|:-+:)\\+)+)");
+      split( substr(block, RSTART, RLENGTH) , talign, /\+/ );
+      # split( gensub( "^(.*\n)?\\+((:?=+:?\\+|(:-+|-+:|:-+:)\\+)+)(\n.*)$", "\\2", "g", block ), talign, /\+/ );
+      # debug("Align: " gensub( "^(.*\n)?\\+((:?=+:?\\+|(:-+|-+:|:-+:)\\+)+)(\n.*)$", "\\2", "g", block ));
+
+      for (cnt = 1; cnt <= cols; cnt++) {
+             if (match(talign[cnt], /:(-+|=+):/)) talign[cnt]="center";
+        else if (match(talign[cnt],  /(-+|=+):/)) talign[cnt]="right";
+        else if (match(talign[cnt], /:(-+|=+)/ )) talign[cnt]="left";
+        else talign[cnt]="";
+      }
+
+      if ( match(block, "^\\+(-+\\+)+\n" \
+                        "(\\|([^\n]+\\|)+\n)+" \
+                         "\\+(:?=+:?\\+)+\n" \
+                       "((\\|([^\n]+\\|)+\n)+" \
+                         "\\+(-+\\+)+(\n|$))+" \
+      ) ) {
+        # table header
+        block = substr(block, match(block, /(\n|$)/) + 1 );
+        while ( match(block, "^\\|([^\n]+\\|)+\n") ) {
+          tmp = substr(block, 1, match(block, /(\n|$)/));
+          gsub( /\\\\/, "\\&#x5C;", tmp); gsub(/\\\|/, "\\&#x7C;", tmp);
+          gsub( /(^\||\|$)/, "", tmp );
+          split(tmp, tread, /\|/);
+          block = substr(block, match(block, /(\n|$)/) + 1 );
+          for (cnt = 1; cnt <= cols; cnt++)
+            tarray[cnt] = tarray[cnt] "\n" tread[cnt];
+        }
+
+        ttext = "<thead>\n<tr>"
+        for (cnt = 1; cnt <= cols; cnt++)
+          ttext = ttext "<th align=\"" talign[cnt] "\">" _nblock(tarray[cnt]) "</th>"
+        ttext = ttext "</tr>\n</thead>"
+      }
+
+      # table body
+      block = substr(block, match(block, /(\n|$)/) + 1 );
+      ttext = ttext "<tbody>\n"
+
+      while ( match(block, /^((\|([^\n]+\|)+\n)+\+(-+\+)+(\n|$))+/ ) ){
+        split("", tarray);
+        while ( match(block, /^\|([^\n]+\|)+\n/) ) {
+          tmp = substr(block, 1, match(block, /(\n|$)/));
+          gsub( /\\\\/, "\\&#x5C;", tmp); gsub(/\\\|/, "\\&#x7C;", tmp);
+          gsub( /(^\||\|$)/, "", tmp);
+          split( tmp, tread, /\|/);
+          block = substr(block, match(block, /(\n|$)/) + 1 );
+          for (cnt = 1; cnt <= cols; cnt++)
+            tarray[cnt] = tarray[cnt] "\n" tread[cnt];
+        }
+        block = substr(block, match(block, /(\n|$)/) + 1 );
+
+        ttext = ttext "<tr>"
+        for (cnt = 1; cnt <= cols; cnt++)
+          ttext = ttext "<td align=\"" talign[cnt] "\">" _nblock(tarray[cnt]) "</td>"
+        ttext = ttext "</tr>\n"
+      }
+      return ret "<table>" ttext "</tbody></table>\n" _nblock(block);
+
+    # Line Blocks (pandoc)
+    } else if ( match(block, /^\| [^\n]*(\n|$)(\| [^\n]*(\n|$)|[ \t]+[^\n[:space:]][^\n]*(\n|$))*/) ) {
+      len = RLENGTH; st = RSTART;
+
+      text = substr(block, 1, len); gsub(/\n[[:space:]]+/, " ", text);
+      gsub(/\n\| /, "\n", text); gsub(/^\| |\n$/, "", text);
+      text = inline(text); gsub(/\n/, "<br>\n", text);
+
+      ret = ret "<div class=\"line-block\">" text "</div>\n"; block =  substr( block, len + 1);
+      continue;
+
+    # Indented Code Block
+    } else if ( match(block, /^((    |\t)[^\n]*[^\n\t ][^\n]*(\n|$))((    |\t)[^\n]*(\n|$)|[\t ]*(\n|$))*/) ) {
+      len = RLENGTH; st = RSTART;
+
+      code = substr(block, 1, len);
+      gsub(/(^|\n)(    |\t)/, "\n", code);
+      gsub(/^\n|\n+$/, "", code);
+      ret = ret "<pre><code>" HTML( code ) "</code></pre>\n"; block = substr( block, len + 1 );
+      continue;
+
+    # Fenced Divs (pandoc, custom)
+    } else if ( match( block, /^(:::+)/ ) ) {
+      guard = substr( block, 1, RLENGTH ); attrib = code = block;
+      sub(/^[^\n]+\n/, "", code);
+      sub(/^:::+[ \t]*\{?[ \t]*/, "", attrib); sub(/\}?[ \t]*\n.*$/, "", attrib);
+      # attrib = gensub(/^:::+[ \t]*\{?[ \t]*([^\}\n]*)\}?[ \t]*\n.*$/, "\\1", 1, attrib);
+      gsub(/[^a-zA-Z0-9_-]+/, " ", attrib);
+      gsub(/(^ | $)/, "", attrib);
+      if ( match(code, "(^|\n)" guard "+(\n|$)" ) && attrib ) {
+        len = RLENGTH; st = RSTART;
+        ret = ret "<div class=\"" attrib "\">" _nblock( substr(code, 1, st - 1) ) "</div>\n";
+        block = substr( code, st + len );
+        continue;
+
+      } else if ( match(code, "(^|\n)" guard "+(\n|$)" ) ) {
+        len = RLENGTH; st = RSTART;
+        ret = ret "<div>" _nblock( substr(code, 1, st - 1) ) "</div>\n"; block = substr( code, st + len );
+        continue;
+
+      } else {
+        match( block, /(^|\n)[[:space:]]*(\n|$)/ ) || match( block, /$/ );
+        len = RLENGTH; st = RSTART;
+        ret = ret "<p>" inline( substr(block, 1, st - 1) ) "</p>\n"; block = substr(block, st + len);
+        continue;
+      }
+
+    # Fenced Code Block (pandoc)
+    } else if ( match( block, /^(~~~+|```+)/ ) ) {
+      guard = substr( block, 1, RLENGTH ); attrib = code = block;
+      sub(/^[^\n]+\n/, "", code);
+      sub(/^(~~~+|```+)[ \t]*\{?[ \t]*/, "", attrib); sub(/\}?[ \t]*\n.*$/, "", attrib);
+      # attrib = gensub(/^(~~~+|```+)[ \t]*\{?[ \t]*([^\}\n]*)\}?[ \t]*\n.*$/, "\\2", 1, attrib);
+      gsub(/[^a-zA-Z0-9_-]+/, " ", attrib);
+      gsub(/(^ | $)/, "", attrib);
+      if ( match(code, "(^|\n)" guard "+(\n|$)" ) && attrib ) {
+        len = RLENGTH; st = RSTART;
+        ret = ret "<pre class=\"" attrib "\"><code class=\"" attrib "\">" \
+                        HTML( substr(code, 1, st - 1) ) "</code></pre>\n";
+        block = substr( code, st + len );
+        continue;
+
+      } else if ( match(code, "(^|\n)" guard "+(\n|$)" ) ) {
+        len = RLENGTH; st = RSTART;
+        ret = ret "<pre><code>" HTML( substr(code, 1, st - 1) ) "</code></pre>\n";
+        block = substr( code, st + len );
+        continue;
+
+      } else {
+        match( block, /(^|\n)[[:space:]]*(\n|$)/ ) || match( block, /$/ );
+        len = RLENGTH; st = RSTART;
+        ret = ret "<p>" inline( substr(block, 1, st - 1) ) "</p>\n"; block = substr(block, st + len);
+        continue;
+      }
+
+    # First Order Heading H1 + Attrib
+    } else if ( match( block, /^([^\n]+)([ \t]*\{([^\}\n]+)\})\n===+(\n|$)/ ) ) {
+      len = RLENGTH; text = attrib = block;
+      sub(/([ \t]*\{([^\}\n]+)\})\n===+(\n.*)?$/, "", text);
+      sub(/\}\n===+(\n.*)?$/, "", attrib); sub(/^([^\n]+)[ \t]*\{/, "", attrib);
+      gsub(/[^a-zA-Z0-9_-]+/, " ", attrib); gsub(/(^ | $)/, "", attrib);
+
+      ret = ret headline(1, text, attrib) ; block = substr( block, len + 1 );
+      continue;
+
+    # First Order Heading H1
+    } else if ( match( block, /^([^\n]+)\n===+(\n|$)/ ) ) {
+      len = RLENGTH; text = substr(block, 1, len);
+      sub(/\n===+(\n.*)?$/, "", text);
+
+      ret = ret headline(1, text, 0) ; block = substr( block, len + 1 );
+      continue;
+
+    # Second Order Heading H2 + Attrib
+    } else if ( match( block, /^([^\n]+)([ \t]*\{([^\}\n]+)\})\n---+(\n|$)/ ) ) {
+      len = RLENGTH; text = attrib = block;
+      sub(/([ \t]*\{([^\}\n]+)\})\n---+(\n.*)?$/, "", text);
+      sub(/\}\n---+(\n.*)?$/, "", attrib); sub(/^([^\n]+)[ \t]*\{/, "", attrib);
+      gsub(/[^a-zA-Z0-9_-]+/, " ", attrib); gsub(/(^ | $)/, "", attrib);
+
+      ret = ret headline(2, text, attrib) ; block = substr( block, len + 1);
+      continue;
+
+    # Second Order Heading H2
+    } else if ( match( block, /^([^\n]+)\n---+(\n|$)/ ) ) {
+      len = RLENGTH; text = substr(block, 1, len);
+      sub(/\n---+(\n.*)?$/, "", text);
+
+      ret = ret headline(2, text, 0) ; block = substr( block, len + 1);
+      continue;
+
+    # # Nth Order Heading H1 H2 H3 H4 H5 H6 + Attrib
+    # } else if ( match( block, /^(##?#?#?#?#?)[ \t]*(([^ \t\n]+|[ \t]+[^ \t\n#]|[ \t]+#+[ \t]*[^ \t\n#])+)[ \t]*#*[ \t]*\{[a-zA-Z \t-]*\}(\n|$)/ ) ) {
+    } else if ( match( block, /^##?#?#?#?#?[^#\n]([^\n#]|#[^\t\n# ]|#[\t ]+[^\t\n ])+#*[\t ]*\{[\ta-zA-Z -]*\}(\n|$)/ ) ) {
+      len = RLENGTH; text = attrib = substr(block, 1, len);
+      match(block, /^##?#?#?#?#?[^#]/); n = RLENGTH - 1;
+      # sub(/^(##?#?#?#?#?)[ \t]*/, "", text);  # not working in mawk
+      text = substr(text, n + 1); sub(/^[ \t]*/, "", text);
+      sub(/[ \t]*#*([ \t]*\{([a-zA-Z \t-]*)\})(\n.*)?$/, "", text);
+
+      sub(/^##?#?#?#?#?[^#\n]([^\n#]|#[^\t\n# ]|#[\t ]+[^\t\n ])+#*[\t ]*\{/, "", attrib);
+      sub(/\}(\n.*)?$/, "", attrib);
+      gsub(/[^a-zA-Z0-9_-]+/, " ", attrib); gsub(/(^ | $)/, "", attrib);
+
+      ret = ret headline( n, text, attrib ); block = substr( block, len + 1);
+      continue;
+
+    # Nth Order Heading H1 H2 H3 H4 H5 H6
+    # } else if ( match( block, /^(##?#?#?#?#?)[ \t]*(([^ \t\n]+|[ \t]+[^ \t\n#]|[ \t]+#+[ \t]*[^ \t\n#])+)[ \t]*#*(\n|$)/ ) ) {
+    } else if ( match( block, /^##?#?#?#?#?[^#\n]([^\n#]|#[^\t\n# ]|#[\t ]+[^\t\n ])+#*(\n|$)/ ) ) {
+      len = RLENGTH; text = substr(block, 1, len);
+      match(block, /^##?#?#?#?#?[^#]/); n = RLENGTH - 1;
+      # sub(/^(##?#?#?#?#?)[ \t]+/, "", text);  # not working in mawk
+      text = substr(text, n + 1); sub(/^[ \t]*/, "", text);
+      sub(/[ \t]*#*(\n.*)?$/, "", text);
+
+      ret = ret headline( n, text, 0 ) ; block = substr( block, len + 1);
+      continue;
+
+    # block images (wrapped in <figure>)
+    } else if ( match(block, "^!" lix "\\([\n\t ]*" lid "([\n\t ]+" lit ")?[\n\t ]*\\)(\\{[a-zA-Z \t-]*\\})?(\n|$)") ) {
+      len = RLENGTH; text = href = title = attrib = substr( block, 1, len);
+
+      sub("^!\\[", "", text);
+      sub("\\]\\([\n\t ]*" lid "([\n\t ]+" lit ")?[\n\t ]*\\)(\\{[a-zA-Z \t-]*\\})?(\n.*)?$", "", text);
+
+      sub("^!" lix "\\([\n\t ]*", "", href);
+      sub("([\n\t ]+" lit ")?[\n\t ]*\\)(\\{[a-zA-Z \t-]*\\})?(\n.*)?$", "", href);
+
+      sub("^!" lix "\\([\n\t ]*" lid, "", title);
+      sub("[\n\t ]*\\)(\\{[a-zA-Z \t-]*\\})?(\n.*)?$", "", title);
+      sub("^[\n\t ]+", "", title);
+
+      sub("^!" lix "\\([\n\t ]*" lid "([\n\t ]+" lit ")?[\n\t ]*\\)", "", attrib);
+      sub("(\n.*)?$", "", attrib);
+      sub(/^\{[ \t]*/, "", attrib); sub(/[ \t]*\}$/, "", attrib); gsub(/[ \t]+/, " ", attrib);
+
+      if ( match(href, /^<.*>$/) ) { sub(/^</, "", href); sub(/>$/, "", href); }
+           if ( match(title, /^".*"$/) ) { sub(/^"/, "", title); sub(/"$/, "", title); }
+      else if ( match(title, /^'.*'$/) ) { sub(/^'/, "", title); sub(/'$/, "", title); }
+      else if ( match(title, /^\(.*\)$/) ) { sub(/^\(/, "", title); sub(/\)$/, "", title); }
+
+      gsub(/^[\t ]+$/, "", text); gsub(/\\/, "", href);
+
+      ret = ret "<figure data-src=\"" HTML(href) "\"" (attrib?" class=\"" HTML(attrib) "\"":"") ">" \
+             "<img src=\"" HTML(href) "\" alt=\"" HTML(text?text:title?title:href) "\"" \
+             (attrib?" class=\"" HTML(attrib) "\"":"") ">" \
+             (title?"<figcaption>" inline(title) "</figcaption>":"") \
+             "</figure>\n\n";
+      block = substr( block, len + 1);
+      continue;
+
+    } else if ( match(block, /^!\[([^]]*)\] ?\[([^]]*)\](\n|$)/ ) ) {
+      len = RLENGTH; text = id = block;
+      sub(/(\n.*)?$/, "", text); sub( /^!\[/, "", text); sub(/\] ?\[([^\n]*)\]$/, "", text);
+      sub(/(\n.*)?$/, "",   id); sub( /^!\[([^\n]*)\] ?\[/, "",   id); sub(/\]$/, "",   id);
+      # text = gensub(/^!\[([^\n]*)\] ?\[([^\n]*)\](\n.*)?$/, "\\1", 1, block);
+      #   id = gensub(/^!\[([^\n]*)\] ?\[([^\n]*)\](\n.*)?$/, "\\2", 1, block);
+      if ( ! id ) id = text;
+      if ( rl_href[id] && rl_title[id] ) {
+        ret = ret "<figure data-src=\"" HTML(rl_href[id]) "\">" \
+                 "<img src=\"" HTML(rl_href[id]) "\" alt=\"" HTML(text) "\">" \
+                 "<figcaption>" inline(rl_title[id]) "</figcaption>" \
+               "</figure>\n\n";
+        block = substr( block, len + 1);
+        continue;
+
+      } else if ( rl_href[id] ) {
+        ret = ret "<figure data-src=\"" HTML(rl_href[id]) "\">" \
+                 "<img src=\"" HTML(rl_href[id]) "\" alt=\"" HTML(text) "\">" \
+               "</figure>\n\n";
+        block = substr( block, len + 1);
+        continue;
+      } else {
+        ret = ret "<p>" HTML(substr(block, 1, len)) "</p>\n" ; block = substr(block, len + 1);
+        continue;
+      }
+
+    # Macros (standalone <<macro>> calls handled as block, so they are not wrapped in paragraph)
+    } else if ( match( block, /^<<(([^>]|>[^>])+)>>(\n|$)/ ) ) {
+      len = RLENGTH; text = block;
+      sub(/^<</, "", text); sub(/>>(\n.*)?$/, "", text);
+      # text = gensub(/^<<(([^>]|>[^>])+)>>(\n.*)?$/, "\\1", 1, block);
+      ret = ret "<code class=\"macro\">" HTML(text) "</code>" ; block = substr(block, len + 1);
+      continue;
+
+    # Definition list
+    } else if (match( block, "^(([ \t]*\n)*[^:\n \t][^\n]+\n" \
+                             "([ \t]*\n)* ? ? ?:[ \t][^\n]+(\n|$)" \
+                            "(([ \t]*\n)* ? ? ?:[ \t][^\n]+(\n|$)" \
+                             "|[^:\n \t][^\n]+(\n|$)" \
+                             "|( ? ? ?\t|  +)[^\n]+(\n|$)" \
+                             "|([ \t]*\n)+( ? ? ?\t|  +)[^\n]+(\n|$))*)+" \
+    )) {
+      list = substr( block, 1, RLENGTH); block = substr( block, RLENGTH + 1);
+      ret = ret "<dl>\n" _dlist( list ) "</dl>\n";
+      continue;
+
+    # Unordered list types
+    } else if ( text = _startlist( block, "ul", "-",   "([+*•]|[0-9]+\\.|#\\.|[0-9]+\\)|#\\))") ) {
+      return ret text;
+    } else if ( text = _startlist( block, "ul", "\\+", "([-*•]|[0-9]+\\.|#\\.|[0-9]+\\)|#\\))") ) {
+      return ret text;
+    } else if ( text = _startlist( block, "ul", "\\*", "([-+•]|[0-9]+\\.|#\\.|[0-9]+\\)|#\\))") ) {
+      return ret text;
+    } else if ( text = _startlist( block, "ul", "•", "([-+*]|[0-9]+\\.|#\\.|[0-9]+\\)|#\\))") ) {
+      return ret text;
+
+    # Ordered list types
+    } else if ( text = _startlist( block, "ol", "[0-9]+\\.", "([-+*•]|#\\.|[0-9]+\\)|#\\))") ) {
+      return ret text;
+    } else if ( text = _startlist( block, "ol", "[0-9]+\\)", "([-+*•]|[0-9]+\\.|#\\.|#\\))") ) {
+      return ret text;
+    } else if ( text = _startlist( block, "ol", "#\\.", "([-+*•]|[0-9]+\\.|[0-9]+\\)|#\\))") ) {
+      return ret text;
+    } else if ( text = _startlist( block, "ol", "#\\)", "([-+*•]|[0-9]+\\.|#\\.|[0-9]+\\))") ) {
+      return ret text;
+
+    # Split paragraphs
+    } else if ( match( block, /(^|\n)[[:space:]]*(\n|$)/) ) {
+      len = RLENGTH; st = RSTART;
+      ret = ret _block( substr(block, 1, st - 1) ) "\n"; block = substr(block, st + len);
+      continue;
+
+    # Horizontal rule
+    # } else if ( match( block, /(^|\n) ? ? ?((\* *){3,}|(- *){3,}|(_ *){3,})($|\n)/) ) {
+    } else if ( match( block, /(^|\n) ? ? ?((\* *)(\* *)(\* *)(\* *)*|(- *)(- *)(- *)(- *)*|(_ *)(_ *)(_ *)(_ *)*)($|\n)/) ) {
+      len = RLENGTH; st = RSTART;
+      ret = ret _block(substr(block, 1, st - 1)) "<hr>\n"; block = substr(block, st + len);
+      continue;
+
+    }  # block patterns end
+
+    # Plain paragraph
+    return ret "<p>" inline(block) "</p>\n";
+  }
+  return ret;
+}
+
+function _startlist(block, type, mark, exclude, LOCAL, st, len, list, indent, it, text) {
+  if (match( block, "(^|\n) ? ? ?" mark "[ \t][^\n]+(\n|$)" \
+                                   "(([ \t]*\n)* ? ? ?" mark "[ \t][^\n]+(\n|$)" \
+                                   "|([ \t]*\n)*( ? ? ?\t|  +)[^\n]+(\n|$)" \
+                                   "|[^\n \t][^\n]+(\n|$))*" ) ) {
+    st = RSTART; len = RLENGTH; list = substr( block, st, len);
+
+    sub("^\n", "", list); match(list, "^(   |  | )?"); indent = RLENGTH;
+    # gsub( "(^|\n) {0," indent "}", "\n", list); sub("^\n", "", list);
+    # emulate greedy range matcher for mawk
+    it = "("; while ( indent > 0 ) { for (k = indent; k > 0; k--) { it = it " "; } it = it "|"; indent--; }
+    sub(/\|$/, ")?", it); sub(/^\($/, "", it);
+    gsub( "(^|\n)" it, "\n", list ); sub("^\n", "", list);
+
+    text = substr(block, 1, st - 1); block = substr(block, st + len);
+    if (match(text, /\n[[:space:]]*\n/)) return 0;
+    if (match(text, "(^|\n) ? ? ?" exclude "[ \t][^\n]+")) return 0;
+    if (match( list, "\n" exclude "[ \t]" )) {
+      block = substr(list, RSTART + 1) block;
+      list = substr(list, 1, RSTART);
+    }
+
+    return _block( text ) "<" type ">\n" _list( list, mark ) "</" type ">\n" _block( block );
+  } else return 0;
+}
+
+function _list (block, mark, p, LOCAL, len, st, text, indent, it, task) {
+  if ( match(block, "^([ \t]*\n)*$")) return;
+
+  match(block, "^" mark "[ \t]"); indent = RLENGTH;
+
+  sub("^" mark "[ \t]", "", block);
+
+  if (match(block, /\n[ \t]*\n/)) p = 1;
+
+  match( block, "\n" mark "[ \t][^\n]+(\n|$)" );
+  st = (RLENGTH == -1) ? length(block) + 1 : RSTART;
+  text = substr(block, 1, st); block = substr(block, st + 1);
+
+  # gsub("\n {0," indent "}", "\n", text);
+  # emulate greedy range matcher for mawk
+  it = "("; while ( indent > 0 ) { for (k = indent; k > 0; k--) { it = it " "; } it = it "|"; indent--; }
+  sub(/\|$/, ")?", it); sub(/^\($/, "", it);
+  gsub("\n" it, "\n", text);
+
+  task = match( text, /^\[ \]/   ) ? "<li class=\"task pending\"><input type=checkbox disabled>"      : \
+         match( text, /^\[-\]/   ) ? "<li class=\"task negative\"><input type=checkbox disabled>"     : \
+         match( text, /^\[\/\]/  ) ? "<li class=\"task partial\"><input type=checkbox disabled>"      : \
+         match( text, /^\[\?\]/  ) ? "<li class=\"task unsure\"><input type=checkbox disabled>"       : \
+         match( text, /^\[[xX]\]/) ? "<li class=\"task done\"><input type=checkbox disabled checked>" : "<li>";
+  sub(/^\[[-? \/xX]\]/, "", text);
+
+  text = _nblock( text );
+  if ( ! p && match( text, "^<p>(</p[^>]|</[^p]|<[^/]|[^<])*</p>\n$" ))
+     gsub( "(^<p>|</p>\n$)", "", text);
+
+  return task text "</li>\n" _list(block, mark, p);
+}
+
+function _dlist (block, LOCAL, len, st, text, indent, it, p) {
+  if (match( block, "^([ \t]*\n)*[^:\n \t][^\n]+\n" )) {
+    len = RLENGTH; text = substr(block, 1, len);
+    gsub( "(^\n*|\n*$)", "", text );
+    return "<dt>" inline( text ) "</dt>\n" _dlist( substr(block, len + 1) );
+  } else if (match( block, "^([ \t]*\n)* ? ? ?:[ \t][^\n]+(\n|$)" \
+                         "([^:\n \t][^\n]+(\n|$)" \
+                         "|( ? ? ?\t|  +)[^\n]+(\n|$)" \
+                         "|([ \t]*\n)+( ? ? ?\t|  +)[^\n]+(\n|$))*" \
+  )) {
+    len = RLENGTH; text = substr(block, 1, len);
+    sub( "^([ \t]*\n)*", "", text);
+    match(text, "^ ? ? ?:(\t| +)"); indent = RLENGTH;
+    sub( "^ ? ? ?:(\t| +)", "", text);
+    # gsub( "(^|\n) {0," indent "}", "\n", text );
+    # emulate greedy range matcher for mawk
+    it = "("; while ( indent > 0 ) { for (k = indent; k > 0; k--) { it = it " "; } it = it "|"; indent--; }
+    sub(/\|$/, ")?", it); sub(/^\($/, "", it);
+    gsub( "(^|\n)" it, "\n", text );
+
+    text = _nblock(text);
+    if (match( text, "^<p>(</p[^>]|</[^p]|<[^/]|[^<])*</p>\n$" ))
+       gsub( "(^<p>|</p>\n$)", "", text);
+
+    return "<dd>" text "</dd>\n" _dlist( substr(block, len + 1) );
+  }
+}
+
+BEGIN {
+  # Global Vars
+  file = ""; rl_href[""] = ""; rl_title[""] = "";
+  if (ENVIRON["MD_HTML"] == "true") { AllowHTML = "true"; }
+  HL[1] = 0; HL[2] = 0; HL[3] = 0; HL[4] = 0; HL[5] = 0; HL[6] = 0;
+  # hls = "0 0 0 0 0 0";
+
+  # Universal Patterns
+  nu = "([^_\\\\]|\\\\.|_[[:alnum:]])"  # not underline (except when escaped, or inside a word)
+  na = "([^*\\\\]|\\\\.)"               # not asterisk (except when escaped)
+  ieu =  "_([^_[:space:]]|[^_[:space:]]" nu "*[^_[:space:]])_"                 # inner <em> (underline)
+  isu = "__([^_[:space:]]|[^_[:space:]]" nu "*[^_[:space:]])__"                # inner <strong> (underline)
+  iea =    "\\*([^*[:space:]]|[^*[:space:]]" na "*[^*[:space:]])\\*"     # inner <em> (asterisk)
+  isa = "\\*\\*([^*[:space:]]|[^*[:space:]]" na "*[^*[:space:]])\\*\\*"  # inner <strong> (asterisk)
+
+  lix="\\[(\\\\[^\n]|[^]\n\\\\[])*\\]"  # link text
+  lid="(<(\\\\[^\n]|[^\n<>\\\\])*>|(\\\\.|[^()\"'\\\\])+|([^<\n\t ()\\\\]|\\\\[^\n])(\\\\[\n]|[^\n\t \\(\\)\\\\])*)"  # link dest
+  lit="(\"(\\\\.|[^\"\\\\])*\"|'(\\\\.|[^'\\\\])*'|\\((\\\\.|[^\\(\\)\\\\])*\\))"  # link text
+  # link text with image def
+  lii="\\[(\\\\[^\n]|[^]\n\\\\[])*(!" lix "\\([\n\t ]*" lid "([\n\t ]+" lit ")?[\n\t ]*\\))?(\\\\[^\n]|[^]\n\\\\[])*\\]"
+
+  # Buffering of full file ist necessary, e.g. to find reference links
+  while (getline) { file = file $0 "\n"; }
+  # Clean up MS-DOS line breaks
+  gsub(/\r\n/, "\n", file);
+
+  # Fill array of reference links
+  f = file; rl_id;
+  re_reflink = "(^|\n) ? ? ?\\[([^]\n]+)\\]: ([^ \t\n]+)(\n?[ \t]+(\"([^\"]+)\"|'([^']+)'|\\(([^)]+)\\)))?(\n|$)";
+  # /(^|\n) ? ? ?\[([^]\n]+)\]: ([^ \t\n]+)(\n?[ \t]+("([^"]+)"|'([^']+)'|\(([^)]+)\)))?(\n|$)/
+  while ( match(f, re_reflink ) ) {
+    tt = th = ti = substr(f, RSTART, RLENGTH); f = substr(f, RSTART + RLENGTH);
+    sub("(^|\n) ? ? ?\\[", "", ti); sub("\\]: ([^ \t\n]+)(\n?[ \t]+(\"([^\"]+)\"|'([^']+)'|\\(([^)]+)\\)))?(\n.*)?$", "", ti);
+    sub("(^|\n) ? ? ?\\[([^]\n]+)\\]: ", "", th); sub("(\n?[ \t]+(\"([^\"]+)\"|'([^']+)'|\\(([^)]+)\\)))?(\n.*)?$", "", th);
+    if (match(tt, "(^|\n) ? ? ?\\[([^]\n]+)\\]: ([^ \t\n]+)(\n?[ \t]+(\"([^\"]+)\"|'([^']+)'|\\(([^)]+)\\)))(\n|$)")) {
+      sub("(^|\n) ? ? ?\\[([^]\n]+)\\]: ([^ \t\n]+)", "", tt); sub("^\n?[ \t]+", "", tt); sub("(\n.*)?$", "", tt);
+    } else { tt = ""; }
+    rl_id = ti; rl_href[rl_id] = th; rl_title[rl_id] = tt;
+    # rl_id           = gensub( re_reflink, "\\2", 1, substr(f, RSTART, RLENGTH) );
+    # rl_href[rl_id]  = gensub( re_reflink, "\\3", 1, substr(f, RSTART, RLENGTH) );
+    # rl_title[rl_id] = gensub( re_reflink, "\\5", 1, substr(f, RSTART, RLENGTH) );
+    # f = substr(f, RSTART + RLENGTH);
+    rl_title[rl_id] = substr( rl_title[rl_id], 2, length(rl_title[rl_id]) - 2 );
+    if ( rl_href[rl_id] ~ /<.*>/ ) rl_href[rl_id] = substr( rl_href[rl_id], 2, length(rl_href[rl_id]) - 2 );
+  }
+  # Clear reflinks from File
+  while( gsub(re_reflink, "\n", file ) );
+  # for (n in rl_href) { debug(n " | " rl_href[n] " | " rl_title[n] ); }
+
+  # Run Block Processing -> The Actual Markdown!
+  printf "%s", _nblock( file );
+}
diff --git a/session.sh b/session.sh
new file mode 100755 (executable)
index 0000000..c3a44e8
--- /dev/null
@@ -0,0 +1,152 @@
+#!/bin/sh
+
+# Copyright 2018 - 2022 Paul Hänsch
+# 
+# Permission to use, copy, modify, and/or distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+# 
+# THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+# SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
+# IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+[ -n "$include_session" ] && return 0
+include_session="$0"
+
+export _DATE="$(date +%s)"
+SESSION_TIMEOUT="${SESSION_TIMEOUT:-7200}"
+
+if ! which uuencode >/dev/null; then
+  uuencode() { busybox uuencode "$@"; }
+fi
+if ! which sha256sum >/dev/null; then
+  sha256sum() { busybox sha256sum "$@"; }
+fi
+
+if which openssl >/dev/null; then
+  session_mac(){ { [ $# -gt 0 ] && printf %s "$*" || cat; } | openssl dgst -sha1 -hmac "$(server_key)" -binary |slopecode; }
+else
+  # Gonzo MAC if openssl is unavailable
+  session_mac(){
+    { server_key | dd status=none bs=256 count=1 skip=1
+      { server_key | dd status=none bs=256 count=1
+        [ $# -gt 0 ] && printf %s "$*" || cat
+      } \
+      | sha256sum -;
+    } \
+    | sha256sum | cut -d\  -f1
+  }
+fi
+
+server_key(){
+  IDFILE="${IDFILE:-${_DATA:-.}/serverkey}"
+  if [ "$(stat -c %s "$IDFILE")" -ne 512 ] || ! cat "$IDFILE"; then
+    dd count=1 bs=512 if=/dev/urandom \
+    | tee "$IDFILE"
+  fi 2>&-
+}
+
+slopecode(){
+  # 6-Bit Code that retains sort order of input data, while beeing safe to use
+  # in ascii transmissions, unix file names, HTTP URLs, and HTML attributes
+
+  { [ $# -gt 0 ] && printf %s "$*" || cat; } \
+  | uuencode -m - | sed '
+    1d;$d; 
+    y;ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/;0123456789:=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz;
+  '
+}
+
+randomid(){
+  dd bs=12 count=1 if=/dev/urandom 2>&- \
+  | slopecode
+}
+
+timeid(){
+  d=$(($_DATE % 4294967296))
+  { printf "$(
+      printf \\%o \
+        $((d / 16777216 % 256)) \
+        $((d / 65536 % 256)) \
+        $((d / 256 % 256)) \
+        $((d % 256))
+    )"
+    dd bs=8 count=1 if=/dev/urandom 2>&-
+  } | slopecode
+}
+
+transid(){
+  # transaction ID to modify a given file
+  local file="$1"
+  session_mac "$(stat -c %F%i%n%N%s%Y "$file" 2>&-)" "$SESSION_ID"
+}
+
+checkid(){ { [ $# -gt 0 ] && printf %s "$*" || cat; } | grep -m 1 -xE '[0-9a-zA-Z:=]{16}'; }
+
+update_session(){
+  local session sid time sig checksig
+  unset SESSION_KEY SESSION_ID
+
+  read -r sid time sig <<-END
+       $(POST session_key || COOKIE session)
+       END
+  
+  checksig="$(session_mac "$sid" "$time")"
+  
+  if [ "$checksig" = "$sig" \
+       -a "$time" -ge "$_DATE" \
+       -a "$(checkid "$sid")" ] 2>&-
+  then
+    time=$(( $_DATE + $SESSION_TIMEOUT ))
+    sig="$(session_mac "$sid" "$time")"
+
+    SESSION_KEY="${sid} ${time} ${sig}"
+    SESSION_ID="${sid}"
+    return 0
+  else
+    return 1
+  fi
+
+}
+
+new_session(){
+  local sid time sig
+
+  debug "Setting up new session"
+  sid="$(randomid)"
+  time=$(( $_DATE + $SESSION_TIMEOUT ))
+  sig="$(session_mac "$sid" "$time")"
+
+  SESSION_KEY="${sid} ${time} ${sig}"
+  SESSION_ID="${sid}"
+}
+
+SESSION_BIND() {
+  # Set tamper-proof authenticated cookie
+  local key="$1" value="$2"
+  SET_COOKIE session "$key"="${value} $(session_mac "$value" "$SESSION_ID")" Path="/${_BASE#/}" SameSite=Strict HttpOnly
+}
+
+SESSION_VAR() {
+  # read authenticated cookie
+  # fail if value has been tampered with
+  local key="$1" value sig
+  value="$(COOKIE "$key")"
+  sig="${value##* }" value="${value% *}"
+  if [ "$sig" = "$(session_mac "$value" "$SESSION_ID")" ]; then
+    printf %s\\n "$value"
+  else
+    return 1
+  fi
+}
+
+SESSION_COOKIE() {
+  [ "$1" = new ] && new_session
+  SET_COOKIE 0 session="$SESSION_KEY" Path="/${_BASE#/}" SameSite=Strict HttpOnly
+}
+
+update_session || new_session
diff --git a/storage.sh b/storage.sh
new file mode 100755 (executable)
index 0000000..5c61df0
--- /dev/null
@@ -0,0 +1,203 @@
+#!/bin/sh
+
+# Copyright 2018 - 2021 Paul Hänsch
+# 
+# Permission to use, copy, modify, and/or distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+# 
+# THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+# SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
+# IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+[ -n "$include_storage" ] && return 0
+include_storage="$0"
+
+CR="\r"
+BR='
+'
+
+LOCK(){
+  local lock="${1}.lock" timeout="${2-20}" block
+
+  if [ \! -w "${lock%/*}" ] || [ -e "$lock" -a \! -f "$lock" ]; then
+    debug "Impossible to get lock: $lock"
+    return 1
+  fi
+
+  while [ $timeout -gt 0 ]; do
+    printf '%i\n' $$ >>"${lock}"
+    read block <"$lock"
+    if [ "$block" = $$ ]; then
+      return 0
+    elif ! { ps -eo pid |grep -qwF "$block"; }; then
+      debug "Trying to override stale lock: $lock"
+      if LOCK "$lock" 1; then
+        rm -- "$lock"
+        RELEASE "$lock"
+      fi
+    else
+      timeout=$((timeout - 1))
+      [ $timeout -gt 0 ] && sleep 1
+    fi
+  done
+
+  debug "Timeout while trying to get lock: $lock"
+  return 1
+}
+
+RELEASE(){
+  local lock="${1}.lock" block
+
+  read block <"$lock"
+  if [ "$block" = $$ ]; then
+    rm -- "$lock"
+    return 0
+  else
+    debug "Refusing to release foreign lock: $lock"
+    return 1
+  fi
+}
+
+STRING(){
+  local in out=''
+  [ $# -gt 0 ] && in="$*" || in="$(cat)"
+  while [ "$in" ]; do case $in in
+    \\*) out="${out}\\\\"; in="${in#\\}" ;;
+    "$BR"*) out="${out}\\n"; in="${in#${BR}}" ;;
+    "$CR"*) out="${out}\\r"; in="${in#${CR}}" ;;
+    "  "*) out="${out}\\t"; in="${in#  }" ;;
+    +*) out="${out}\\+"; in="${in#+}" ;;
+    " "*) out="${out}+"; in="${in# }" ;;
+    *) out="${out}${in%%[\\${CR}${BR}  + ]*}"; in="${in#"${in%%[\\${BR}${CR}   + ]*}"}" ;;
+  esac; done
+  printf '%s' "${out:-\\}"
+}
+
+UNSTRING(){
+  local in out=''
+  [ $# -gt 0 ] && in="$*" || in="$(cat)"
+  while [ "$in" ]; do case $in in
+    \\\\*) out="${out}\\"; in="${in#\\\\}" ;;
+    \\n*) out="${out}${BR}"; in="${in#\\n}" ;;
+    \\r*) out="${out}${CR}"; in="${in#\\r}" ;;
+    \\t*) out="${out}  "; in="${in#\\t}" ;;
+    \\+*) out="${out}+"; in="${in#\\+}" ;;
+    +*) out="${out} "; in="${in#+}" ;;
+    \\*) in="${in#\\}" ;;
+    *) out="${out}${in%%[\\+]*}"; in="${in#"${in%%[\\+]*}"}" ;;
+  esac; done
+  printf '%s\n' "$out"
+}
+
+RXLITERAL(){
+  # sed -E 's;[].*+?^${}()|\[];\\&;g'
+  local in out=''
+  [ $# -gt 0 ] && in="$*" || in="$(cat)"
+  while [ "$in" ]; do case $in in
+    [.+^\$\{\}\(\)\[\]\*\?\|\\]*)
+      out="${out}\\${in%"${in#?}"}"; in="${in#?}";
+      ;;
+    *)out="${out}${in%%[.+^\$\{\}\(\)\[\]\*\?\|\\]*}"
+      in="${in#"${in%%[.+^\$\{\}\(\)\[\]\*\?\|\\]*}"}"
+      ;;
+  esac; done
+  printf '%s\n' "$out"
+}
+
+DBM() {
+  local file="$1" cmd="$2"
+  local k v key value
+  shift 2;
+
+  case "$cmd" in
+    check|contains)
+      key="$(STRING "$1")"
+      while read -r k v; do if [ "$k" = "$key" ]; then
+        return 0
+      fi; done <"$file" 2>&-
+      return 1
+      ;;
+    get)
+      key="$(STRING "$1")"
+      while read -r k v; do if [ "$k" = "$key" ]; then
+        UNSTRING "$v"
+        return 0
+      fi; done <"$file" 2>&-
+      return 1
+      ;;
+    set|store)
+      key="$(STRING "$1")" value="$(STRING "$2")"
+      LOCK "$file" || return 1
+      { while read -r k v; do
+          [ "$k" = "$key" ] || printf '%s\t%s\n' "$k" "$v"
+        done <"$file" 2>&-
+        printf '%s\t%s\n' "$key" "$value"
+      } >"${file}.$$.tmp"
+      mv "${file}.$$.tmp" "${file}"
+      RELEASE "$file"
+      return 0
+      ;;
+    add|insert)
+      k="$1" key="$(STRING "$1")" value="$(STRING "$2")"
+      LOCK "$file" || return 1
+      if DBM "$file" check "$k"; then
+        RELEASE "$file"
+        return 1
+      else
+        printf '%s\t%s\n' "$key" "$value" >>"${file}"
+        RELEASE "$file"
+        return 0
+      fi
+      ;;
+    update|replace)
+      k="$1" key="$(STRING "$1")" value="$(STRING "$2")"
+      LOCK "$file" || return 1
+      if ! DBM "$file" check "$k"; then
+        RELEASE "$file"
+        return 1
+      fi
+      { while read -r k v; do
+          [ "$k" = "$key" ] \
+          && printf '%s\t%s\n' "$key" "$value" \
+          || printf '%s\t%s\n' "$k" "$v"
+        done <"$file" 2>&-
+      } >"${file}.$$.tmp"
+      mv "${file}.$$.tmp" "${file}"
+      RELEASE "$file"
+      return 0
+      ;;
+    append)
+      key="$(STRING "$1")" value="$(STRING "$2")"
+      LOCK "$file" || return 1
+      if ! DBM "$file" check "$1"; then
+        RELEASE "$file"
+        return 1
+      fi
+      { while read -r k v; do
+          [ "$k" = "$key" ] \
+          && printf '%s\t%s\n' "$key" "$v$value" \
+          || printf '%s\t%s\n' "$k" "$v"
+        done <"$file" 2>&-
+      } >"${file}.$$.tmp"
+      mv "${file}.$$.tmp" "${file}"
+      RELEASE "$file"
+      return 0
+      ;;
+    delete|remove)
+      key="$(STRING "$1")"
+      LOCK "$file" || return 1
+      { while read -r k v; do
+          [ "$k" = "$key" ] || printf '%s\t%s\n' "$k" "$v"
+        done <"$file" 2>&-
+      } >"${file}.$$.tmp"
+      mv "${file}.$$.tmp" "${file}"
+      RELEASE "$file"
+      return 0
+      ;;
+  esac
+}
diff --git a/tests-markdown.sh b/tests-markdown.sh
new file mode 100755 (executable)
index 0000000..30442cc
--- /dev/null
@@ -0,0 +1,1234 @@
+#!/bin/sh
+
+runtimes="gawk busybox mawk goawk"
+
+BR='
+'
+CR="$(printf \r)"
+fail() { printf '%s\n' "$@"; exit 1; }
+
+awk() { /bin/awk "$@"; }
+md_gawk() { gawk -f markdown.awk "$@"; }
+md_busybox() { busybox awk -f markdown.awk "$@"; }
+md_mawk() { mawk -f markdown.awk "$@"; }
+md_goawk() { goawk -f markdown.awk "$@"; }
+
+acnt=1  # assertion count
+assert() {
+  local md comp="$2" msg="$3" ex
+  printf "%3i: %s ... " $acnt "$msg"
+
+  for proc in $runtimes; do
+    printf '%s ' $proc
+    md="$(printf '%s' "$1" |md_"$proc")"; ex=$?
+    if [ "$ex" != 0 ]; then
+      printf "Fail!\nExit Code: %i\n" $ex
+      exit 1
+    fi
+    if [ "$md" != "$comp" ]; then
+      printf "Fail!\n:\n%s\n:\n%s\n" "$md" "$comp"
+      exit 1
+    fi
+  done
+  printf 'OK\n'
+  acnt=$((acnt + 1))
+}
+
+# Inline checks
+printf '## Testing Inline markup ##\n'
+
+# strong / em / ...
+assert '~~strikeout~~' '<p><del>strikeout</del></p>' "strikeout"
+assert '~~~strikeout~~' '<p><del>~strikeout</del></p>' "strikeout"
+assert '^super^' '<p><sup>super</sup></p>' "superscript"
+assert '~sub~' '<p><sub>sub</sub></p>' "subscript"
+
+assert "foo  ${BR}bar" "<p>foo<br>${BR}bar</p>" 'double space line break'
+assert '```&copy;```' "<p><code>&amp;copy;</code></p>" "code span escape"
+assert '````' "<pre><code>````</code></pre>" "empty code span"
+
+assert '_emphasized text_' '<p><em>emphasized text</em></p>' "em"
+assert '_emphasized_text_' '<p><em>emphasized_text</em></p>' "em"
+assert 'empha*sized* text_' '<p>empha<em>sized</em> text_</p>' "em"
+assert '__empha*sized* text__' '<p><strong>empha<em>sized</em> text</strong></p>' "strong em"
+assert '***strem***' '<p><strong><em>strem</em></strong></p>' "strong em"
+assert '***str**em*' '<p><em><strong>str</strong>em</em></p>' "em strong"
+assert '_**strem**_' '<p><em><strong>strem</strong></em></p>' "em strong"
+
+assert '*foo**str**bar**str**qua*' '<p><em>foo<strong>str</strong>bar<strong>str</strong>qua</em></p>' 'em strong asterisk'
+assert '**foo*em*bar*em*qua**' '<p><strong>foo<em>em</em>bar<em>em</em>qua</strong></p>' 'strong em asterisk'
+
+assert '_foo__str__bar__str__qua_' '<p><em>foo__str__bar__str__qua</em></p>' 'em embedded underscore'
+assert '__foo_em_bar_em_qua__' '<p><strong>foo_em_bar_em_qua</strong></p>' 'strong embedded underscore'
+assert '_**str**foo**str**_' '<p><em><strong>str</strong>foo<strong>str</strong></em></p>' 'em strong mixed'
+
+assert '_foo_-> bar' '<p><em>foo</em>&rarr; bar</p>' 'arrow'
+assert '`_foo_-> bar`' '<p><code>_foo_-&gt; bar</code></p>' 'arrow'
+assert '<!-- comment --> <- comment' '<p>&lt;!-- comment --&gt; &larr; comment</p>' 'arrow'
+
+# Escaping
+assert '&copy;' "<p>&copy;</p>" "escape"
+assert '\&copy;' "<p>&amp;copy;</p>" "escape"
+assert 'AT&T' "<p>AT&amp;T</p>" "escape"
+assert '`&copy;`' "<p><code>&amp;copy;</code></p>" "code span escape"
+
+# Wiki Links
+assert '[[Link/]]' '<p><a href="Link/">Link/</a></p>' "Wiki Link"
+assert '[[Link/|Linked Page]]' '<p><a href="Link/">Linked Page</a></p>' "Wiki Link"
+
+# Automatic Links
+assert '<https://de.wikipedia.org>' "<p><a href=\"https://de.wikipedia.org\">https://de.wikipedia.org</a></p>" "automatic link"
+assert '<http://de.wikipedia.org>' "<p><a href=\"http://de.wikipedia.org\">http://de.wikipedia.org</a></p>" "automatic link"
+# assert '<//de.wikipedia.org>' "<p><a href=\"//de.wikipedia.org\">http://de.wikipedia.org</a></p>" "automatic link"
+
+assert '<hello&goodbye@sub-test.example.com>' "<p><a href=\"mailto:hello&amp;goodbye@sub-test.example.com\">hello&amp;goodbye@sub-test.example.com</a></p>" "automatic link, email"
+# assert '<hällö&guttbei@sub-test.example.com>' "<p><a href=\"mailto:hällö&amp;guttbei@sub-test.example.com\">hällö&amp;guttbei@sub-test.example.com</a></p>" "automatic link, email"
+
+# Inline Links
+assert '[Wikipedia](http://de.wikipedia.org)' "<p><a href=\"http://de.wikipedia.org\">Wikipedia</a></p>" "inline link"
+assert '[Wikipedia](http://de.wikipedia.org "Online Encyclopedia")' "<p><a href=\"http://de.wikipedia.org\" title=\"Online Encyclopedia\">Wikipedia</a></p>" "inline link"
+assert '[Wikipedia](<http://de.wikipedia.org> "Online Encyclopedia")' "<p><a href=\"http://de.wikipedia.org\" title=\"Online Encyclopedia\">Wikipedia</a></p>" "inline link"
+
+# Inline Images (note leading white space)
+assert ' ![Testbild](Test Bild.jpg)' '<p> <img src="Test Bild.jpg" alt="Testbild"></p>' "inline image"
+assert ' ![Testbild](Test Bild.jpg "German Television *test* image ca. 1994")' '<p> <img src="Test Bild.jpg" alt="Testbild" title="German Television *test* image ca. 1994"></p>' "inline image"
+assert ' ![Testbild *ARD*](Test Bild.jpg){tv ard function-check}' '<p> <img src="Test Bild.jpg" alt="Testbild *ARD*" class="tv ard function-check"></p>' "inline image"
+# assert ' ![Testbild *ARD*](Test Bild.jpg){#tv .ard .function-check}' '<p> <img src="Test Bild.jpg" alt="Testbild *ARD*" class="tv ard check"></p>' "inline image id/classes"
+
+assert '[![Wikipedia](wikilogo.png)](<http://de.wikipedia.org>)'\
+       '<p><a href="http://de.wikipedia.org"><img src="wikilogo.png" alt="Wikipedia"></a></p>'\
+       "Image Link"
+
+assert ' <<macro /test -- "* weird <args>" _foo_>>' '<p> <code class="macro">macro /test -- &quot;* weird &lt;args&gt;&quot; _foo_</code></p>' "Macros"
+
+# Block checks
+printf '\n## Testing Block markup ##\n'
+
+assert \
+'foo
+
+bar' \
+'<p>foo</p>
+
+<p>bar</p>' \
+'paragraphs'
+
+assert '%meta *data block*
+  ignored `no` __formatting__
+regular *data*' \
+'<p>regular <em>data</em></p>' \
+"meta data block"
+
+assert '> text in a block
+> quote can be *emphasized*
+and quotes continued
+
+until they end' \
+'<blockquote><p>text in a block
+quote can be <em>emphasized</em>
+and quotes continued</p></blockquote>
+
+<p>until they end</p>' \
+'block quote'
+
+assert '| text in a line
+| block can be *emphasized*
+but not continued
+
+until they end' \
+'<div class="line-block">text in a line<br>
+block can be <em>emphasized</em></div>
+<p>but not continued</p>
+
+<p>until they end</p>' \
+'pandoc line block'
+
+assert '    indented code will
+       not be
+       *formatted*
+    but &shy; <escaped>' \
+'<pre><code>indented code will
+not be
+*formatted*
+but &amp;shy; &lt;escaped&gt;</code></pre>' \
+"indented code block"
+
+assert '    indented code will
+       not be
+
+       *formatted*
+    but &shy; <escaped>' \
+'<pre><code>indented code will
+not be
+
+*formatted*
+but &amp;shy; &lt;escaped&gt;</code></pre>' \
+"indented code block"
+
+assert ':::: tag
+fenced _divs_ are regular text
+
+:::
+and can contain another div
+:::
+::::' \
+'<div class="tag"><p>fenced <em>divs</em> are regular text</p>
+
+<div><p>and can contain another div</p>
+</div>
+</div>' \
+"pandoc fenced divs"
+
+assert '``` tag,code
+fenced code will
+not be
+*formatted*
+but &shy; <escaped>
+```' \
+'<pre class="tag code"><code class="tag code">fenced code will
+not be
+*formatted*
+but &amp;shy; &lt;escaped&gt;</code></pre>' \
+"fenced code block"
+
+assert 'foobar
+````
+foobar' \
+'<p>foobar
+````
+foobar</p>' \
+"Open Fence"
+
+# Block Images
+assert '![Testbild](Test Bild.jpg)' \
+'<figure data-src="Test Bild.jpg"><img src="Test Bild.jpg" alt="Testbild"></figure>' \
+"block image"
+
+assert '![Testbild](Test Bild.jpg "German Television *test* image ca. 1994")' \
+'<figure data-src="Test Bild.jpg"><img src="Test Bild.jpg" alt="Testbild"><figcaption>German Television <em>test</em> image ca. 1994</figcaption></figure>' \
+"block image"
+
+assert '![Testbild *ARD*](Test Bild.jpg){tv ard function-check}' \
+'<figure data-src="Test Bild.jpg" class="tv ard function-check"><img src="Test Bild.jpg" alt="Testbild *ARD*" class="tv ard function-check"></figure>' \
+"block image tagged"
+
+# assert '![Testbild *ARD*](Test Bild.jpg){#tv .ard .function-check}' \
+# '<figure data-src="Test Bild.jpg" class="tv ard function-check"><img src="Test Bild.jpg" alt="Testbild *ARD*" class="tv ard function-check"></figure>' \
+# "block image tagged"
+
+# Headings
+assert 'Heading first Order
+============' \
+'<section class="h1" id="1:Heading%20first%20Order"><h1>Heading first Order<a class="anchor" href="#1:Heading%20first%20Order"></a></h1>
+</section>' \
+'Heading h1'
+
+assert 'Heading first Order {.foo #bar}
+============' \
+'<section class="h1 foo bar" id="1:Heading%20first%20Order"><h1 class="foo bar">Heading first Order<a class="anchor" href="#1:Heading%20first%20Order"></a></h1>
+</section>' \
+'Heading h1 + attributes'
+
+assert 'Heading second Order
+------------' \
+'<section class="h2" id="0.1:Heading%20second%20Order"><h2>Heading second Order<a class="anchor" href="#0.1:Heading%20second%20Order"></a></h2>
+</section>' \
+'Heading h2'
+
+assert 'Heading second Order {.foo #bar}
+------------' \
+'<section class="h2 foo bar" id="0.1:Heading%20second%20Order"><h2 class="foo bar">Heading second Order<a class="anchor" href="#0.1:Heading%20second%20Order"></a></h2>
+</section>' \
+'Heading h2 + attributes'
+
+assert '#### Heading four' \
+'<section class="h4" id="0.0.0.1:Heading%20four"><h4>Heading four<a class="anchor" href="#0.0.0.1:Heading%20four"></a></h4>
+</section>' \
+'Heading arbitrary'
+
+assert '###Heading three ######' \
+'<section class="h3" id="0.0.1:Heading%20three"><h3>Heading three<a class="anchor" href="#0.0.1:Heading%20three"></a></h3>
+</section>' \
+'Heading arbitrary'
+
+assert '### Heading three ## {foo bar}' \
+'<section class="h3 foo bar" id="0.0.1:Heading%20three"><h3 class="foo bar">Heading three<a class="anchor" href="#0.0.1:Heading%20three"></a></h3>
+</section>' \
+'Heading arbitrary + attributes'
+
+assert '# Heading \# # {foo bar}' \
+'<section class="h1 foo bar" id="1:Heading%20%5C%23"><h1 class="foo bar">Heading #<a class="anchor" href="#1:Heading%20%5C%23"></a></h1>
+</section>' \
+'Heading arbitrary + attributes'
+
+assert 'Definition
+: term
+with line continuation
+
+: second term
+
+foo
+: bar' \
+'<dl>
+<dt>Definition</dt>
+<dd>term
+with line continuation</dd>
+<dd>second term</dd>
+<dt>foo</dt>
+<dd>bar</dd>
+</dl>' \
+'Definition List'
+
+assert ' * list
+* item
+  1. sub list
+* three
+- new list' \
+'<ul>
+<li>list</li>
+<li><p>item</p>
+<ol>
+<li>sub list</li>
+</ol>
+</li>
+<li>three</li>
+</ul>
+<ul>
+<li>new list</li>
+</ul>' \
+'Lists'
+
+assert '::: outer div
+Nesting paragraph
+
+-------
+
+> ```
+> quoted code
+> ```
+> 
+> > quoted quote
+:::
+' \
+'<div class="outer div"><p>Nesting paragraph</p>
+
+<hr>
+
+<blockquote><pre><code>quoted code</code></pre>
+<blockquote><p>quoted quote</p></blockquote>
+</blockquote>
+
+</div>' \
+"Nesting"
+
+assert '
+| Col 1 | Col 2| Col 3 |
+|-------|-------|------:|
+| foo   | *bar* | `qua` |
+| 23    | 47 | 11 |
+' \
+'<table><thead>
+<tr><th align=""> Col 1 </th><th align=""> Col 2</th><th align="right"> Col 3 </th></tr>
+</thead><tbody>
+<tr><td align=""> foo   </td><td align=""> <em>bar</em> </td><td align="right"> <code>qua</code> </td></tr>
+<tr><td align=""> 23    </td><td align=""> 47 </td><td align="right"> 11 </td></tr>
+</tbody></table>' \
+'Pipe Tables'
+
+# assert '
+#  Col 1 | Col 2| Col 3 
+# :-----:|-------|------:
+#  foo   | *bar* | `qua` 
+#  23    | 47 | 11 |
+# ' \
+# '<table><thead>
+# <tr><th align="center"> Col 1 </th><th align=""> Col 2</th><th align="right"> Col 3 </th></tr>
+# </thead><tbody>
+# <tr><td align="center"> foo   </td><td align=""> <em>bar</em> </td><td align="right"> <code>qua</code> </td></tr>
+# <tr><td align="center"> 23    </td><td align=""> 47 </td><td align="right"> 11 </td></tr>
+# </tbody></table>' \
+# 'Pipe Tables'
+
+assert '+---+---+---+
+|Col 1\\| Col\|2 |  Col 3|
++===+:==:+===+
+| * foo1   | *bar* |```|
+| * foo2   | **qua** |code |
+| - foo3   | `quux` |```|
++-------+-----+----+
+| 23    | 47 | 11 |
++-------+-----+----+
+' \
+'<table><thead>
+<tr><th align=""><p>Col 1&#x5C;</p>
+</th><th align="center"><p> Col&#x7C;2 </p>
+</th><th align=""><p>  Col 3</p>
+</th></tr>
+</thead><tbody>
+<tr><td align=""><ul>
+<li>foo1   </li>
+<li>foo2   </li>
+</ul>
+<ul>
+<li>foo3   </li>
+</ul>
+</td><td align="center"><p> <em>bar</em> 
+ <strong>qua</strong> 
+ <code>quux</code> </p>
+</td><td align=""><pre><code>code </code></pre>
+</td></tr>
+<tr><td align=""><p> 23    </p>
+</td><td align="center"><p> 47 </p>
+</td><td align=""><p> 11 </p>
+</td></tr>
+</tbody></table>' \
+'Grid Tables'
+
+assert '## foo
+
+# bar
+
+sub bar
+-------
+
+### sub sub sub ###
+
+##sub2 bar {x}
+' \
+'<section class="h2" id="0.1:foo"><h2>foo<a class="anchor" href="#0.1:foo"></a></h2>
+</section><section class="h1" id="1:bar"><h1>bar<a class="anchor" href="#1:bar"></a></h1>
+<section class="h2" id="1.1:sub%20bar"><h2>sub bar<a class="anchor" href="#1.1:sub%20bar"></a></h2>
+<section class="h3" id="1.1.1:sub%20sub%20sub"><h3>sub sub sub<a class="anchor" href="#1.1.1:sub%20sub%20sub"></a></h3>
+</section></section><section class="h2 x" id="1.2:sub2%20bar"><h2 class="x">sub2 bar<a class="anchor" href="#1.2:sub2%20bar"></a></h2>
+</section></section>' \
+'Headline Nesting'
+
+# Reference syntax checks
+printf '\n## Testing reference syntax ##\n'
+
+assert 'Foo bar [Link] [1] for show
+
+The same in [en][]
+
+[en]: <http://en.wikipedia.org>
+[1]: http://de.wikipedia.org "Online Encyclopedia"' \
+'<p>Foo bar <a href="http://de.wikipedia.org" title="Online Encyclopedia">Link</a> for show</p>
+
+<p>The same in <a href="http://en.wikipedia.org">en</a></p>' \
+"Reference Links"
+
+assert 'Foo bar [Link] [1] for show
+
+[en]: <http://en.wikipedia.org>
+[1]: http://de.wikipedia.org
+ "Online Encyclopedia"' \
+'<p>Foo bar <a href="http://de.wikipedia.org" title="Online Encyclopedia">Link</a> for show</p>' \
+"Reference Links"
+
+assert 'Foo bar ![Image] [1] for show
+
+The same as ![PNG][]
+
+[PNG]: <mage/path/i.png>
+[1]: http://de.wikipedia.org/logo.jpg "Online Encyclopedia"' \
+'<p>Foo bar <img src="http://de.wikipedia.org/logo.jpg" alt="Image" title="Online Encyclopedia"> for show</p>
+
+<p>The same as <img src="mage/path/i.png" alt="PNG"></p>' \
+"Reference images"
+
+assert '![Image] [1]
+
+[PNG]: <mage/path/i.png>
+[1]: http://de.wikipedia.org/logo.jpg "Online Encyclopedia"' \
+'<figure data-src="http://de.wikipedia.org/logo.jpg"><img src="http://de.wikipedia.org/logo.jpg" alt="Image"><figcaption>Online Encyclopedia</figcaption></figure>' \
+"Reference images (block)"
+
+assert '<<macro /test -- "* weird <args>" _foo_>>' '<code class="macro">macro /test -- &quot;* weird &lt;args&gt;&quot; _foo_</code>' "Macros/Block"
+
+
+printf '\n## Testing example pages ##\n'
+
+assert 'Markdown.awk
+============
+
+Supported Features / TODO:
+--------------------------
+- [x] done
+- [ ] todo
+- [-] not planned
+- ?  unsure (whether to implement)
+- [/] partial
+
+### Basic Markdown - Block elements: ###
+- [x] Paragraphs
+  - [x] Double space line breaks
+- [x] Proper block element nesting
+- [x] Headings
+- [x] ATX-Style Headings
+- [x] Blockquotes
+- [x] Lists (ordered, unordered)
+- [x] Code blocks (using indention)
+- [x] Horizontal rules
+- [x] Verbatim HTML block (disabled by default)
+
+### Basic Markdown - Inline elements: ###
+- [x] Links
+- [x] Reference style links
+- [x] Emphasis *em*/**strong** (*Asterisk*, _Underscore_)
+- [x] `code`, also ``code containing `backticks` ``
+- [x] Images / reference style images
+- [x] <automatic links>
+- [x] backslash escapes
+- [x] Verbatim HTML inline (disabled by default)
+- [x] HTML escaping
+
+NOTE: Set the environment variable `MD_HTML=true` to enable verbatim HTML
+
+### Extensions - Block elements: ###
+- [x] Automatic <section>-wrapping (custom)
+-  ?  Heading identifiers (php md, pandoc)
+  - [x] Heading attributes (custom)
+- [x] Automatic heading identifiers (custom)
+- [x] Fenced code blocks (php md, pandoc)
+  - [x] Fenced code attributes
+- [x] Images (as block elements, <figure>-wrapped) (custom)
+  - [x] reference style block images
+- [/] Tables
+  -  ?  Simple table (pandoc)
+  -  ?  Multiline table (pandoc)
+  - [x] Grid table (pandoc)
+    - [x] Headerless
+  - [x] Pipe table (php md, pandoc)
+- [x] Line blocks (pandoc)
+- [x] Task lists (pandoc, custom)
+- [x] Definition lists (php md, pandoc)
+- [-] Numbered example lists (pandoc)
+- [-] Metadata blocks (pandoc)
+- [x] Metadata blocks (custom)
+- [x] Fenced Divs (pandoc)
+
+### Extensions - Inline elements: ###
+- [x] Ignore embedded_underscores (php md, pandoc)
+- [x] ~~strikeout~~ (pandoc)
+- [x] ^Superscript^ ~Subscript~ (pandoc)
+- [-] Bracketed spans (pandoc)
+  - [-] Inline attributes (pandoc)
+- [x] Image attributes (custom, pandoc inspired, not for reference style)
+- [x] Wiki style links [[PageName]] / [[PageName|Link Text]]
+- [-] TEX-Math (pandoc)
+-  ?  Footnotes (php md)
+-  ?  Abbreviations (php md)
+-  ?  "Curly quotes" (smartypants)
+- [ ] em-dashes (--) (smartypants old)
+-  ?  ... three-dot ellipsis (smartypants)
+- [-] en-dash (smartypants)
+- [ ] Automatic em-dash / en-dash
+- [x] Automatic -> Arrows <- (custom)
+
+Compatibility
+-------------
+Markdown.awk can run in GNU awk (`gawk`) and in Busybox awk. It is _not_ fully POSIX compliant and does not run in `mawk` or `nawk`. In particular it makes heavy use of the `gensub()` function and its ability to use paranthesized subexpressions in the replacement text. This feature is not available in the POSIX specified `sub()` and `gsub()` functions. Hence it cannot be replaced without effort.
+
+Tests
+-----
+[Link with Title](https://en.wikipedia.org/wiki/Markdown "Markdown in Wikipedia"), *emphasis*, **strong**, **strong containing *emphasis***, `inline code`, `` code with `backticks` ``. See more tests [here](./tests/).' \
+'<section class="h1" id="1:Markdown.awk"><h1>Markdown.awk<a class="anchor" href="#1:Markdown.awk"></a></h1>
+<section class="h2" id="1.1:Supported%20Features%20/%20TODO:"><h2>Supported Features / TODO:<a class="anchor" href="#1.1:Supported%20Features%20/%20TODO:"></a></h2>
+<ul>
+<li class="task done"><input type=checkbox disabled checked> done</li>
+<li class="task pending"><input type=checkbox disabled> todo</li>
+<li class="task negative"><input type=checkbox disabled> not planned</li>
+<li>?  unsure (whether to implement)</li>
+<li class="task partial"><input type=checkbox disabled> partial</li>
+</ul>
+<section class="h3" id="1.1.1:Basic%20Markdown%20-%20Block%20elements:"><h3>Basic Markdown - Block elements:<a class="anchor" href="#1.1.1:Basic%20Markdown%20-%20Block%20elements:"></a></h3>
+<ul>
+<li class="task done"><input type=checkbox disabled checked><p> Paragraphs</p>
+<ul>
+<li class="task done"><input type=checkbox disabled checked> Double space line breaks</li>
+</ul>
+</li>
+<li class="task done"><input type=checkbox disabled checked> Proper block element nesting</li>
+<li class="task done"><input type=checkbox disabled checked> Headings</li>
+<li class="task done"><input type=checkbox disabled checked> ATX-Style Headings</li>
+<li class="task done"><input type=checkbox disabled checked> Blockquotes</li>
+<li class="task done"><input type=checkbox disabled checked> Lists (ordered, unordered)</li>
+<li class="task done"><input type=checkbox disabled checked> Code blocks (using indention)</li>
+<li class="task done"><input type=checkbox disabled checked> Horizontal rules</li>
+<li class="task done"><input type=checkbox disabled checked> Verbatim HTML block (disabled by default)</li>
+</ul>
+</section><section class="h3" id="1.1.2:Basic%20Markdown%20-%20Inline%20elements:"><h3>Basic Markdown - Inline elements:<a class="anchor" href="#1.1.2:Basic%20Markdown%20-%20Inline%20elements:"></a></h3>
+<ul>
+<li class="task done"><input type=checkbox disabled checked> Links</li>
+<li class="task done"><input type=checkbox disabled checked> Reference style links</li>
+<li class="task done"><input type=checkbox disabled checked> Emphasis <em>em</em>/<strong>strong</strong> (<em>Asterisk</em>, <em>Underscore</em>)</li>
+<li class="task done"><input type=checkbox disabled checked> <code>code</code>, also <code>code containing `backticks`</code></li>
+<li class="task done"><input type=checkbox disabled checked> Images / reference style images</li>
+<li class="task done"><input type=checkbox disabled checked> &lt;automatic links&gt;</li>
+<li class="task done"><input type=checkbox disabled checked> backslash escapes</li>
+<li class="task done"><input type=checkbox disabled checked> Verbatim HTML inline (disabled by default)</li>
+<li class="task done"><input type=checkbox disabled checked> HTML escaping</li>
+</ul>
+<p>NOTE: Set the environment variable <code>MD_HTML=true</code> to enable verbatim HTML</p>
+
+</section><section class="h3" id="1.1.3:Extensions%20-%20Block%20elements:"><h3>Extensions - Block elements:<a class="anchor" href="#1.1.3:Extensions%20-%20Block%20elements:"></a></h3>
+<ul>
+<li class="task done"><input type=checkbox disabled checked> Automatic &lt;section&gt;-wrapping (custom)</li>
+<li><p> ?  Heading identifiers (php md, pandoc)</p>
+<ul>
+<li class="task done"><input type=checkbox disabled checked> Heading attributes (custom)</li>
+</ul>
+</li>
+<li class="task done"><input type=checkbox disabled checked> Automatic heading identifiers (custom)</li>
+<li class="task done"><input type=checkbox disabled checked><p> Fenced code blocks (php md, pandoc)</p>
+<ul>
+<li class="task done"><input type=checkbox disabled checked> Fenced code attributes</li>
+</ul>
+</li>
+<li class="task done"><input type=checkbox disabled checked><p> Images (as block elements, &lt;figure&gt;-wrapped) (custom)</p>
+<ul>
+<li class="task done"><input type=checkbox disabled checked> reference style block images</li>
+</ul>
+</li>
+<li class="task partial"><input type=checkbox disabled><p> Tables</p>
+<ul>
+<li> ?  Simple table (pandoc)</li>
+<li> ?  Multiline table (pandoc)</li>
+<li class="task done"><input type=checkbox disabled checked><p> Grid table (pandoc)</p>
+<ul>
+<li class="task done"><input type=checkbox disabled checked> Headerless</li>
+</ul>
+</li>
+<li class="task done"><input type=checkbox disabled checked> Pipe table (php md, pandoc)</li>
+</ul>
+</li>
+<li class="task done"><input type=checkbox disabled checked> Line blocks (pandoc)</li>
+<li class="task done"><input type=checkbox disabled checked> Task lists (pandoc, custom)</li>
+<li class="task done"><input type=checkbox disabled checked> Definition lists (php md, pandoc)</li>
+<li class="task negative"><input type=checkbox disabled> Numbered example lists (pandoc)</li>
+<li class="task negative"><input type=checkbox disabled> Metadata blocks (pandoc)</li>
+<li class="task done"><input type=checkbox disabled checked> Metadata blocks (custom)</li>
+<li class="task done"><input type=checkbox disabled checked> Fenced Divs (pandoc)</li>
+</ul>
+</section><section class="h3" id="1.1.4:Extensions%20-%20Inline%20elements:"><h3>Extensions - Inline elements:<a class="anchor" href="#1.1.4:Extensions%20-%20Inline%20elements:"></a></h3>
+<ul>
+<li class="task done"><input type=checkbox disabled checked> Ignore embedded_underscores (php md, pandoc)</li>
+<li class="task done"><input type=checkbox disabled checked> <del>strikeout</del> (pandoc)</li>
+<li class="task done"><input type=checkbox disabled checked> <sup>Superscript</sup> <sub>Subscript</sub> (pandoc)</li>
+<li class="task negative"><input type=checkbox disabled><p> Bracketed spans (pandoc)</p>
+<ul>
+<li class="task negative"><input type=checkbox disabled> Inline attributes (pandoc)</li>
+</ul>
+</li>
+<li class="task done"><input type=checkbox disabled checked> Image attributes (custom, pandoc inspired, not for reference style)</li>
+<li class="task done"><input type=checkbox disabled checked> Wiki style links <a href="PageName">PageName</a> / <a href="PageName">Link Text</a></li>
+<li class="task negative"><input type=checkbox disabled> TEX-Math (pandoc)</li>
+<li> ?  Footnotes (php md)</li>
+<li> ?  Abbreviations (php md)</li>
+<li> ?  &quot;Curly quotes&quot; (smartypants)</li>
+<li class="task pending"><input type=checkbox disabled> em-dashes (--) (smartypants old)</li>
+<li> ?  ... three-dot ellipsis (smartypants)</li>
+<li class="task negative"><input type=checkbox disabled> en-dash (smartypants)</li>
+<li class="task pending"><input type=checkbox disabled> Automatic em-dash / en-dash</li>
+<li class="task done"><input type=checkbox disabled checked> Automatic &rarr; Arrows &larr; (custom)</li>
+</ul>
+</section></section><section class="h2" id="1.2:Compatibility"><h2>Compatibility<a class="anchor" href="#1.2:Compatibility"></a></h2>
+<p>Markdown.awk can run in GNU awk (<code>gawk</code>) and in Busybox awk. It is <em>not</em> fully POSIX compliant and does not run in <code>mawk</code> or <code>nawk</code>. In particular it makes heavy use of the <code>gensub()</code> function and its ability to use paranthesized subexpressions in the replacement text. This feature is not available in the POSIX specified <code>sub()</code> and <code>gsub()</code> functions. Hence it cannot be replaced without effort.</p>
+
+</section><section class="h2" id="1.3:Tests"><h2>Tests<a class="anchor" href="#1.3:Tests"></a></h2>
+<p><a href="https://en.wikipedia.org/wiki/Markdown" title="Markdown in Wikipedia">Link with Title</a>, <em>emphasis</em>, <strong>strong</strong>, <strong>strong containing <em>emphasis</em></strong>, <code>inline code</code>, <code>code with `backticks`</code>. See more tests <a href="./tests/">here</a>.</p>
+</section></section>' \
+'Full Page (cgilite markdown)'
+
+assert 'Headline First Order
+====================
+
+Headline Second Order
+---------------------
+
+    Code Block
+    with indentation
+
+> Blockquote
+> ----------
+> like in an email
+
+### Headline 3rd order
+
+- unordered List
+1. with sub points
+     
+   sometimes longer ones
+
+2. which are ordered
+3. [ ] and have a Todo item
+- more list points
+  - and a sublist
+- [x] some of which ae done
+
+----------
++ A lazy, lazy, list
+item.
+
++ Another one; this looks
+bad but is legal.
+
+    Second paragraph of second
+list item.
+
+---------
+
+~~~ {.blue}
+Fenced Code Block
+# with verbatim Text
+`and an attribute`
+~~~
+
+| The limerick packs laughs anatomical
+| In space that is quite economical.
+|    But the *good* ones I'\''ve seen
+|    So seldom are *clean*
+| And the clean ones so seldom are comical
+
+| The Right Honorable Most Venerable and Righteous Samuel L.
+  Constable, Jr.
+| 200 Main St.
+| Berkeley, CA 94718
+
+Term 1
+
+:   This is a definition with two paragraphs. Lorem ipsum 
+    dolor sit amet, consectetuer adipiscing elit. Aliquam 
+    hendrerit mi posuere lectus.
+
+    Vestibulum enim wisi, viverra nec, fringilla in, laoreet
+    vitae, risus.
+
+:   Second definition for term 1, also wrapped in a paragraph
+    because of the blank line preceding it.
+
+Term 2
+
+:   This definition has a code block, a blockquote and a list.
+
+        code block.
+
+    > block quote
+    > on two lines.
+
+    1.  first list item
+    2.  second list item' \
+'<section class="h1" id="1:Headline%20First%20Order"><h1>Headline First Order<a class="anchor" href="#1:Headline%20First%20Order"></a></h1>
+<section class="h2" id="1.1:Headline%20Second%20Order"><h2>Headline Second Order<a class="anchor" href="#1.1:Headline%20Second%20Order"></a></h2>
+<pre><code>Code Block
+with indentation</code></pre>
+<blockquote><section class="h2" id="1/0.1:Blockquote"><h2>Blockquote<a class="anchor" href="#1/0.1:Blockquote"></a></h2>
+<p>like in an email</p>
+</section></blockquote>
+
+<section class="h3" id="1.1.1:Headline%203rd%20order"><h3>Headline 3rd order<a class="anchor" href="#1.1.1:Headline%203rd%20order"></a></h3>
+<ul>
+<li>unordered List</li>
+</ul>
+<ol>
+<li><p>with sub points</p>
+
+<p>sometimes longer ones</p>
+</li>
+<li><p>which are ordered</p>
+</li>
+<li class="task pending"><input type=checkbox disabled><p> and have a Todo item</p>
+</li>
+</ol>
+<ul>
+<li><p>more list points</p>
+<ul>
+<li>and a sublist</li>
+</ul>
+</li>
+<li class="task done"><input type=checkbox disabled checked> some of which ae done</li>
+</ul>
+<hr>
+<ul>
+<li><p>A lazy, lazy, list
+item.</p>
+</li>
+<li><p>Another one; this looks
+bad but is legal.</p>
+
+<p>  Second paragraph of second
+list item.</p>
+</li>
+</ul>
+<hr>
+
+<pre class="blue"><code class="blue">Fenced Code Block
+# with verbatim Text
+`and an attribute`</code></pre>
+<div class="line-block">The limerick packs laughs anatomical<br>
+In space that is quite economical.<br>
+   But the <em>good</em> ones I&#x27;ve seen<br>
+   So seldom are <em>clean</em><br>
+And the clean ones so seldom are comical</div>
+<div class="line-block">The Right Honorable Most Venerable and Righteous Samuel L. Constable, Jr.<br>
+200 Main St.<br>
+Berkeley, CA 94718</div>
+<dl>
+<dt>Term 1</dt>
+<dd><p>This is a definition with two paragraphs. Lorem ipsum 
+dolor sit amet, consectetuer adipiscing elit. Aliquam 
+hendrerit mi posuere lectus.</p>
+
+<p>Vestibulum enim wisi, viverra nec, fringilla in, laoreet
+vitae, risus.</p>
+</dd>
+<dd>Second definition for term 1, also wrapped in a paragraph
+because of the blank line preceding it.</dd>
+<dt>Term 2</dt>
+<dd><p>This definition has a code block, a blockquote and a list.</p>
+
+<pre><code>code block.</code></pre>
+<blockquote><p>block quote
+on two lines.</p></blockquote>
+
+<ol>
+<li> first list item</li>
+<li> second list item</li>
+</ol>
+</dd>
+</dl>
+</section></section></section>' \
+'Full Page (MD Tests)'
+
+assert '%css shellwiki.css
+
+Shellwiki
+=========
+Shellwiki is a Wiki and Content Management System with minimal dependencies. It can run on embedded devices, as well as full size web servers.  Its goals are:
+
+  - **easy deployment**
+
+    *ShellWiki* can run on any Unix-Like web server. It requires no
+    scripting languages beyound the regular (Bourne style) Unix
+    shell, `awk`, and `sed`, all of which can be providede by
+    `busybox`. It can be launched via `netcat`, `inetd`, `systemd`,
+    or any cgi capable webserver like `apache` or `lighttpd`.  
+    *ShellWiki* can run easily on embedded systems like OpenWRT or
+    RaspberryPi, and just as easily on internet web servers
+    providing multisite setups.
+
+  - **accessibility**
+
+    *ShellWiki* requires no browserside scripting. It aims to be rendered
+    in all web browsers including `w3m` and `links` besides graphical
+    browsers like `chromium` or `firefox`. It is as accessible on mobile
+    screens as on desktop computers.  
+    *ShellWiki* uses the well known `markdown` syntax for formatting and
+    aims to provide consistent UI controls for various use cases.
+
+  - **adaptability**
+
+    *ShellWiki* is extensible through plugins and provides theming and
+    styling capabilities that make it suitable not only as a wiki, but
+    also as a CMS, including access scopes for different authors and
+    stylisticly distinct subpages.
+
+  - **simplicity**
+
+    *ShellWiki* avoids complexity in both software design and user
+    interface. It aims to be secure and predictable. Extensions can
+    be written and modified by system administrators.  
+
+<<toc 2 2>>
+
+Features
+--------
+  - **Markdown Wiki Syntax**
+
+    The wiki syntax is based on [John Grubers Markdown](https://daringfireball.net/projects/markdown/)
+    with extensions inspired by [Pandoc](https://pandoc.org/MANUAL.html#pandocs-markdown),
+    [PHP Markdown Extra](https://michelf.ca/projects/php-markdown/extra/), and
+    [Github Flavored Markdown](https://github.github.com/gfm/).
+    Additional Macros are provided to enable functions like an automatic table of contents, listing of
+    sub pages, etc.
+
+    See [Markdown](/software/cgilite/markdown/)  
+    and [Macros](macros/)
+
+  - **Plain file Storage**
+
+    Pages and attachments are stored as plain files on disk. There is no need for a separate database
+    system.
+
+  - **Git revisioning**
+
+    If `git` is available on the web server, pages can be revisioned so that past versions can be
+    revisited. Optionally attachments can be revisioned too. Server administrators may use the git
+    archives to synchronise sites across servers by adding their own mechanics.
+
+  - **Multisite Installation**
+
+    Code and data directories are stricly separate on the server. Directory pathes are obtained from
+    environment variables, so that multiple sites can be served from the same installation directory.
+
+    See also: [Installation](installation/)
+
+  - **Semantic HTML5**
+
+    for accessible rendering of pages
+
+  - **Descriptive Page Names**
+
+    URLs of pages can be freely provided by the user. User access can be constrained to specific sub
+    pages. Within their access permissions users can move and rename pages as they like.
+
+  - **File Upload / Attachment**
+
+    While pages are merely text documents themselves, users can upload additional attachments and
+    link to them in a page. Images and media files can be embedded directly into a page.
+
+  - **Image scaling**
+
+    If `ImageMagick` is available on the web server, huge attachment images are automatically compressed
+    and scaled to HD resolution when referred to in a page.
+    Of course the original version can still be linked to.
+
+  - **Permissions via ACL**
+
+    Grant read/write access for pages and sub-pages
+
+  - **User provided CSS**
+
+    Aside from full theming in the installation directory, pages can be styled using CSS files
+    uploaded as attachments.
+
+  - **No reliance on Javascript**
+
+    Authors and visitors can use the site without being forced to run untrusted code.
+    The main theme still provides collapsible menus and a responsive layout.
+
+  - **Complete GDPR Compliance** without consent walls
+
+    Because shellwiki does not track page visitors and does not
+    serve cookies to visitors by default it does not need to coerce
+    visitors into handling GDPR "consent" forms.
+
+    (Login for authors still requires a session cookie)
+
+  - **True multilanguage capability**
+
+    - Pages can be translated
+    - Switching language does not require a cookie
+    - Fallback language for missing translations
+    - Users stay on a translated version, even if single page translations are missing
+
+  - **Full text indexing and search**
+
+    Shellwiki contains its own basic text indexer without external dependencies.
+
+  - **Extensibility** through
+
+    - [Themes](themes/)
+    - [Macros](macros/)
+    - [URL Handlers](handlers/)
+    - [Custom Syntax parsers](parsers/)
+
+Dependencies
+------------
+Shellwiki is based on [cgilite](/software/cgilite/), which is included in the installation. It is written in posix compliant shell script, and the markdown renderer is written in ~~posix compliant~~ AWK. The entire wiki system can run with nothing more than a busybox. In fact it can be served from the rescue shell in a Debian initrd, or from an OpenWRT router.
+
+**Its precise requirements are:**
+
+ - A Posix Shell (as provided by busybox, but bash is OK)
+ - An AWK interpreter (as provided by busybox, but GNU AWK is OK)
+   - `mawk` and `nawk` will currently not work
+ - inetd (as provided by busybox)
+
+   **or** any CGI-Capable web server
+
+ - _Optional:_ GIT for revisioning
+ - _Optional:_ ImageMagick for image compression
+ - _Optional:_ Sendmail for sending password reminders, etc.
+
+Installation
+------------
+Also see -> [[installation/]]
+
+You can try out shellwiki right now using busybox:
+
+    ~$ git clone https://git.plutz.net/git/shellwiki ~/shellwiki
+    ~$ _DATA=~/wikidata busybox nc -llp 1080 -e ~/shellwiki/index.cgi
+
+For additional examples, regarding permanent installation and configuration in webservers see [[installation/]].
+
+Syntax
+------
+The wiki syntax is based on John Grubers [Markdown](https://daringfireball.net/projects/markdown/) with extensions borrowed from [Pandoc](https://pandoc.org/MANUAL%202.html#pandocs-markdown) and [PHP Markdown Extra](https://michelf.ca/projects/php-markdown/extra/). The Markdown parser is provided by [Cgilite](/software/cgilite/) and its full documentation can be looked at [here](/software/cgilite/markdown/).
+
+<<include --nolink /[wiki]/editorhelp/>>
+
+Macros
+------
+Also see -> [[macros/]]
+
+In addition to the Markdown syntax, wiki pages can include Macros, which perform additional functions on a page, like generating an image gallery, including parts of other pages, etc. Macros make Shellwiki truly dynamic and flexible.
+
+For example you can include a table of content for the current page by including the line
+
+    <<toc>>
+
+in your page. Macros can receive additional parameters, which modify their behaviour.
+
+Macros are the most easy to write type of extension. See [Macros](macros/) for a full list of available macros.
+
+Themes
+------
+Also see -> [[theming/]]
+
+While Shellwiki supports plugins for [theming](dev-theming/), it'\''s apearance can mostly be configured by the user. Pages can be configured to use custom CSS files. In addition page headers and footers are themselves wiki pages which can be modified to add menus, custom logos, links, etc. The same goes for error pages.
+
+For an example, see the [technical pages](/[wiki]/) for this wiki.
+
+Multiple Languages
+------------------
+To enable a multilingual setup you must set a default language in your configuration environment:
+
+```
+export LANGUAGE_DEFAULT=en
+```
+
+Once this is the case, pagenames starting with a colon (`:`) will be considered translated versions of their parent pages. I.e. the pages `/`, `/:de`, and `/:fr` will serve as the default, german, and french home page respectively.
+
+The names of the languages can be arbitrary, but I recommend using [ISO-639](https://en.wikipedia.org/wiki/ISO_639-1) codes, because the code is used in the `lang=""` attribute of the pages top level html element. You can however make up non-standardised or fantastic language names as well.
+
+Links on each page will automatically be suffixed with the same language tag, so a visitor keeps browsing the same language without needing a cookie. Attachments should only be uploaded to the default language page, and attachment links in the translated pages will correctly point to the main page attachments. You can create a language menu on the header page, simply by linking to `./:en`, `./:es` , `./:fr`, etc.
+
+Header, footer, and error pages will be included from their respective language version, as will all macro includes, etc. Should a page not exist in a given language, the default page will be displayed instead. However, included elements will still be taken from the respective language version, possibly mixing languages between the selected user language and the default.
+
+### Constraints of the current implementation
+ - There can be only one default language, with no priority of different fallback languages
+ - Page URLs can currently not be translated. Doing so would require a model for manually assigning translated page names and would not be trivial to use.
+
+Developer Documentation
+-----------------------
+How to write:
+
+ - [Themes](dev-theming/)
+ - [Macros](dev-macros/)
+ - [Handlers](dev-handlers/)
+ - [Parsers](dev-parsers/)' \
+'<section class="h1" id="1:Shellwiki"><h1>Shellwiki<a class="anchor" href="#1:Shellwiki"></a></h1>
+<p>Shellwiki is a Wiki and Content Management System with minimal dependencies. It can run on embedded devices, as well as full size web servers.  Its goals are:</p>
+<ul>
+<li><p><strong>easy deployment</strong></p>
+
+<p><em>ShellWiki</em> can run on any Unix-Like web server. It requires no
+scripting languages beyound the regular (Bourne style) Unix
+shell, <code>awk</code>, and <code>sed</code>, all of which can be providede by
+<code>busybox</code>. It can be launched via <code>netcat</code>, <code>inetd</code>, <code>systemd</code>,
+or any cgi capable webserver like <code>apache</code> or <code>lighttpd</code>.<br>
+<em>ShellWiki</em> can run easily on embedded systems like OpenWRT or
+RaspberryPi, and just as easily on internet web servers
+providing multisite setups.</p>
+</li>
+<li><p><strong>accessibility</strong></p>
+
+<p><em>ShellWiki</em> requires no browserside scripting. It aims to be rendered
+in all web browsers including <code>w3m</code> and <code>links</code> besides graphical
+browsers like <code>chromium</code> or <code>firefox</code>. It is as accessible on mobile
+screens as on desktop computers.<br>
+<em>ShellWiki</em> uses the well known <code>markdown</code> syntax for formatting and
+aims to provide consistent UI controls for various use cases.</p>
+</li>
+<li><p><strong>adaptability</strong></p>
+
+<p><em>ShellWiki</em> is extensible through plugins and provides theming and
+styling capabilities that make it suitable not only as a wiki, but
+also as a CMS, including access scopes for different authors and
+stylisticly distinct subpages.</p>
+</li>
+<li><p><strong>simplicity</strong></p>
+
+<p><em>ShellWiki</em> avoids complexity in both software design and user
+interface. It aims to be secure and predictable. Extensions can
+be written and modified by system administrators.  </p>
+</li>
+</ul>
+<code class="macro">toc 2 2</code><section class="h2" id="1.1:Features"><h2>Features<a class="anchor" href="#1.1:Features"></a></h2>
+<ul>
+<li><p><strong>Markdown Wiki Syntax</strong></p>
+
+<p>The wiki syntax is based on <a href="https://daringfireball.net/projects/markdown/">John Grubers Markdown</a>
+with extensions inspired by <a href="https://pandoc.org/MANUAL.html#pandocs-markdown">Pandoc</a>,
+<a href="https://michelf.ca/projects/php-markdown/extra/">PHP Markdown Extra</a>, and
+<a href="https://github.github.com/gfm/">Github Flavored Markdown</a>.
+Additional Macros are provided to enable functions like an automatic table of contents, listing of
+sub pages, etc.</p>
+
+<p>See <a href="/software/cgilite/markdown/">Markdown</a><br>
+and <a href="macros/">Macros</a></p>
+</li>
+<li><p><strong>Plain file Storage</strong></p>
+
+<p>Pages and attachments are stored as plain files on disk. There is no need for a separate database
+system.</p>
+</li>
+<li><p><strong>Git revisioning</strong></p>
+
+<p>If <code>git</code> is available on the web server, pages can be revisioned so that past versions can be
+revisited. Optionally attachments can be revisioned too. Server administrators may use the git
+archives to synchronise sites across servers by adding their own mechanics.</p>
+</li>
+<li><p><strong>Multisite Installation</strong></p>
+
+<p>Code and data directories are stricly separate on the server. Directory pathes are obtained from
+environment variables, so that multiple sites can be served from the same installation directory.</p>
+
+<p>See also: <a href="installation/">Installation</a></p>
+</li>
+<li><p><strong>Semantic HTML5</strong></p>
+
+<p>for accessible rendering of pages</p>
+</li>
+<li><p><strong>Descriptive Page Names</strong></p>
+
+<p>URLs of pages can be freely provided by the user. User access can be constrained to specific sub
+pages. Within their access permissions users can move and rename pages as they like.</p>
+</li>
+<li><p><strong>File Upload / Attachment</strong></p>
+
+<p>While pages are merely text documents themselves, users can upload additional attachments and
+link to them in a page. Images and media files can be embedded directly into a page.</p>
+</li>
+<li><p><strong>Image scaling</strong></p>
+
+<p>If <code>ImageMagick</code> is available on the web server, huge attachment images are automatically compressed
+and scaled to HD resolution when referred to in a page.
+Of course the original version can still be linked to.</p>
+</li>
+<li><p><strong>Permissions via ACL</strong></p>
+
+<p>Grant read/write access for pages and sub-pages</p>
+</li>
+<li><p><strong>User provided CSS</strong></p>
+
+<p>Aside from full theming in the installation directory, pages can be styled using CSS files
+uploaded as attachments.</p>
+</li>
+<li><p><strong>No reliance on Javascript</strong></p>
+
+<p>Authors and visitors can use the site without being forced to run untrusted code.
+The main theme still provides collapsible menus and a responsive layout.</p>
+</li>
+<li><p><strong>Complete GDPR Compliance</strong> without consent walls</p>
+
+<p>Because shellwiki does not track page visitors and does not
+serve cookies to visitors by default it does not need to coerce
+visitors into handling GDPR &quot;consent&quot; forms.</p>
+
+<p>(Login for authors still requires a session cookie)</p>
+</li>
+<li><p><strong>True multilanguage capability</strong></p>
+<ul>
+<li>Pages can be translated</li>
+<li>Switching language does not require a cookie</li>
+<li>Fallback language for missing translations</li>
+<li>Users stay on a translated version, even if single page translations are missing</li>
+</ul>
+</li>
+<li><p><strong>Full text indexing and search</strong></p>
+
+<p>Shellwiki contains its own basic text indexer without external dependencies.</p>
+</li>
+<li><p><strong>Extensibility</strong> through</p>
+<ul>
+<li><a href="themes/">Themes</a></li>
+<li><a href="macros/">Macros</a></li>
+<li><a href="handlers/">URL Handlers</a></li>
+<li><a href="parsers/">Custom Syntax parsers</a></li>
+</ul>
+</li>
+</ul>
+</section><section class="h2" id="1.2:Dependencies"><h2>Dependencies<a class="anchor" href="#1.2:Dependencies"></a></h2>
+<p>Shellwiki is based on <a href="/software/cgilite/">cgilite</a>, which is included in the installation. It is written in posix compliant shell script, and the markdown renderer is written in <del>posix compliant</del> AWK. The entire wiki system can run with nothing more than a busybox. In fact it can be served from the rescue shell in a Debian initrd, or from an OpenWRT router.</p>
+
+<p><strong>Its precise requirements are:</strong></p>
+<ul>
+<li><p>A Posix Shell (as provided by busybox, but bash is OK)</p>
+</li>
+<li><p>An AWK interpreter (as provided by busybox, but GNU AWK is OK)</p>
+<ul>
+<li><code>mawk</code> and <code>nawk</code> will currently not work</li>
+</ul>
+</li>
+<li><p>inetd (as provided by busybox)</p>
+
+<p><strong>or</strong> any CGI-Capable web server</p>
+</li>
+<li><p><em>Optional:</em> GIT for revisioning</p>
+</li>
+<li><p><em>Optional:</em> ImageMagick for image compression</p>
+</li>
+<li><p><em>Optional:</em> Sendmail for sending password reminders, etc.</p>
+</li>
+</ul>
+</section><section class="h2" id="1.3:Installation"><h2>Installation<a class="anchor" href="#1.3:Installation"></a></h2>
+<p>Also see &rarr; <a href="installation/">installation/</a></p>
+
+<p>You can try out shellwiki right now using busybox:</p>
+
+<pre><code>~$ git clone https://git.plutz.net/git/shellwiki ~/shellwiki
+~$ _DATA=~/wikidata busybox nc -llp 1080 -e ~/shellwiki/index.cgi</code></pre>
+<p>For additional examples, regarding permanent installation and configuration in webservers see <a href="installation/">installation/</a>.</p>
+
+</section><section class="h2" id="1.4:Syntax"><h2>Syntax<a class="anchor" href="#1.4:Syntax"></a></h2>
+<p>The wiki syntax is based on John Grubers <a href="https://daringfireball.net/projects/markdown/">Markdown</a> with extensions borrowed from <a href="https://pandoc.org/MANUAL%202.html#pandocs-markdown">Pandoc</a> and <a href="https://michelf.ca/projects/php-markdown/extra/">PHP Markdown Extra</a>. The Markdown parser is provided by <a href="/software/cgilite/">Cgilite</a> and its full documentation can be looked at <a href="/software/cgilite/markdown/">here</a>.</p>
+
+<code class="macro">include --nolink /[wiki]/editorhelp/</code></section><section class="h2" id="1.5:Macros"><h2>Macros<a class="anchor" href="#1.5:Macros"></a></h2>
+<p>Also see &rarr; <a href="macros/">macros/</a></p>
+
+<p>In addition to the Markdown syntax, wiki pages can include Macros, which perform additional functions on a page, like generating an image gallery, including parts of other pages, etc. Macros make Shellwiki truly dynamic and flexible.</p>
+
+<p>For example you can include a table of content for the current page by including the line</p>
+
+<pre><code>&lt;&lt;toc&gt;&gt;</code></pre>
+<p>in your page. Macros can receive additional parameters, which modify their behaviour.</p>
+
+<p>Macros are the most easy to write type of extension. See <a href="macros/">Macros</a> for a full list of available macros.</p>
+
+</section><section class="h2" id="1.6:Themes"><h2>Themes<a class="anchor" href="#1.6:Themes"></a></h2>
+<p>Also see &rarr; <a href="theming/">theming/</a></p>
+
+<p>While Shellwiki supports plugins for <a href="dev-theming/">theming</a>, it&#x27;s apearance can mostly be configured by the user. Pages can be configured to use custom CSS files. In addition page headers and footers are themselves wiki pages which can be modified to add menus, custom logos, links, etc. The same goes for error pages.</p>
+
+<p>For an example, see the <a href="/[wiki]/">technical pages</a> for this wiki.</p>
+
+</section><section class="h2" id="1.7:Multiple%20Languages"><h2>Multiple Languages<a class="anchor" href="#1.7:Multiple%20Languages"></a></h2>
+<p>To enable a multilingual setup you must set a default language in your configuration environment:</p>
+
+<pre><code>export LANGUAGE_DEFAULT=en</code></pre>
+<p>Once this is the case, pagenames starting with a colon (<code>:</code>) will be considered translated versions of their parent pages. I.e. the pages <code>/</code>, <code>/:de</code>, and <code>/:fr</code> will serve as the default, german, and french home page respectively.</p>
+
+<p>The names of the languages can be arbitrary, but I recommend using <a href="https://en.wikipedia.org/wiki/ISO_639-1">ISO-639</a> codes, because the code is used in the <code>lang=""</code> attribute of the pages top level html element. You can however make up non-standardised or fantastic language names as well.</p>
+
+<p>Links on each page will automatically be suffixed with the same language tag, so a visitor keeps browsing the same language without needing a cookie. Attachments should only be uploaded to the default language page, and attachment links in the translated pages will correctly point to the main page attachments. You can create a language menu on the header page, simply by linking to <code>./:en</code>, <code>./:es</code> , <code>./:fr</code>, etc.</p>
+
+<p>Header, footer, and error pages will be included from their respective language version, as will all macro includes, etc. Should a page not exist in a given language, the default page will be displayed instead. However, included elements will still be taken from the respective language version, possibly mixing languages between the selected user language and the default.</p>
+
+<section class="h3" id="1.7.1:Constraints%20of%20the%20current%20implementation"><h3>Constraints of the current implementation<a class="anchor" href="#1.7.1:Constraints%20of%20the%20current%20implementation"></a></h3>
+<ul>
+<li>There can be only one default language, with no priority of different fallback languages</li>
+<li>Page URLs can currently not be translated. Doing so would require a model for manually assigning translated page names and would not be trivial to use.</li>
+</ul>
+</section></section><section class="h2" id="1.8:Developer%20Documentation"><h2>Developer Documentation<a class="anchor" href="#1.8:Developer%20Documentation"></a></h2>
+<p>How to write:</p>
+<ul>
+<li><a href="dev-theming/">Themes</a></li>
+<li><a href="dev-macros/">Macros</a></li>
+<li><a href="dev-handlers/">Handlers</a></li>
+<li><a href="dev-parsers/">Parsers</a></li>
+</ul>
+</section></section>' \
+'Full Page (ShellWiki)'
+
+printf '\nAll tests passed!\n'
diff --git a/users.sh b/users.sh
new file mode 100755 (executable)
index 0000000..32299ff
--- /dev/null
+++ b/users.sh
@@ -0,0 +1,661 @@
+#!/bin/sh
+
+# Copyright 2021 - 2024 Paul Hänsch
+# 
+# Permission to use, copy, modify, and/or distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+# 
+# THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+# SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
+# IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+[ -n "$include_users" ] && return 0
+include_users="$0"
+
+. "${_EXEC:-.}/cgilite/session.sh"
+. "${_EXEC:-.}/cgilite/storage.sh"
+
+SENDMAIL=${SENDMAIL-sendmail}
+
+USER_REGISTRATION="${USER_REGISTRATION-true}"
+USER_REQUIREEMAIL="${USER_REQUIREEMAIL-true}"
+USER_ACCOUNTPAGE="${USER_ACCOUNTPAGE}"
+
+USER_ACCOUNTEXPIRE="${USER_ACCOUNTEXPIRE:-$((86400 * 730))}"
+USER_CONFIRMEXPIRE="${USER_CONFIRMEXPIRE:-86400}"
+
+HTTP_HOST="$(HEADER Host)"
+MAILFROM="noreply@${HTTP_HOST%:*}"
+
+[ "$HTTPS" ] && SCHEMA=https || SCHEMA=http
+
+# == FILE FORMAT ==
+# UID  UNAME   STATUS  EMAIL   PWSALT  PWHASH  EXPIRE  DEVICES FUTUREUSE
+#              (pending|active|deleted)
+
+# == GLOBALS ==
+UNSET_USER='unset \
+  USER_ID USER_NAME USER_STATUS USER_EMAIL USER_PWSALT USER_PWHASH \
+  USER_EXPIRE USER_DEVICES USER_FUTUREUSE
+'
+
+LOCAL_USER='local \
+  USER_ID USER_NAME USER_STATUS USER_EMAIL USER_PWSALT USER_PWHASH \
+  USER_EXPIRE USER_DEVICES USER_FUTUREUSE
+'
+
+# == TRANSLATIONS ==
+# override all functions marked with "TRANSLATION"
+# sed -n '/TRANSLATION$/,/^}/p;' <cgilite/users.sh
+
+unset USER_IDMAP
+eval "$UNSET_USER"
+
+user_db="${user_db:-${_DATA}/users.db}"
+
+read_user() {
+  local user="$1"
+
+  # Global exports
+  USER_ID='' USER_NAME='' USER_STATUS='' USER_EMAIL='' USER_PWSALT=''
+  USER_PWHASH='' USER_EXPIRE='' USER_DEVICES='' USER_FUTUREUSE=''
+
+  if [ $# -eq 0 ]; then
+    read -r USER_ID USER_NAME USER_STATUS USER_EMAIL USER_PWSALT USER_PWHASH \
+            USER_EXPIRE USER_DEVICES USER_FUTUREUSE
+  elif [ "$user" -a -f "$user_db" -a -r "$user_db" ]; then
+    read -r USER_ID USER_NAME USER_STATUS USER_EMAIL USER_PWSALT USER_PWHASH \
+            USER_EXPIRE USER_DEVICES USER_FUTUREUSE <<-EOF
+       $(grep "^${user}        " "${user_db}")
+       EOF
+  fi
+  if [ "$USER_ID" -a "${USER_EXPIRE:-0}" -gt "$_DATE" ]; then
+       USER_NAME="$(UNSTRING "$USER_NAME")"
+      USER_EMAIL="$(UNSTRING "$USER_EMAIL")"
+    USER_DEVICES="$(UNSTRING "$USER_DEVICES")"
+    unset USER_PWSALT USER_PWHASH
+  else
+    eval "$UNSET_USER"
+    return 1
+  fi
+}
+
+update_user() {
+  # internal function for user update
+  local uid="$1" uname status email pwsalt pwhash expire devices futureuse
+  local UID_ UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES FUTUREUSE
+  local arg
+
+  for arg in "$@"; do case $arg in
+    uname=*) uname="${arg#*=}";;
+    status=*) status="${arg#*=}";;
+    email=*) email="${arg#*=}";;
+    password=*) pwsalt="$(randomid)"; pwhash="$(user_pwhash "$pwsalt" "${arg#*=}")";;
+    expire=*) expire="${arg#*=}";;
+    devices=*) devices="${arg#*=}";;
+  esac; done
+
+  if LOCK "$user_db"; then
+    while read -r UID_ UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES \
+                  FUTUREUSE; do
+    if [ "$UID_" = "$uid" ]; then
+      printf '%s       %s      %s      %s      %s      %s      %i      %s      %s\n' \
+             "$uid" "$(STRING "${uname-$(UNSTRING "$UNAME")}")" \
+             "${status:-${status-${STATUS}}${status+\\}}" \
+             "${email:-${email-${EMAIL}}${email+\\}}" \
+             "${pwsalt:-${PWSALT}}" "${pwhash:-${PWHASH}}" \
+             "${expire:-$((_DATE + USER_ACCOUNTEXPIRE))}" \
+             "$(STRING "${devices-$(UNSTRING "$DEVICES")}")" \
+             "${FUTUREUSE:-\\}"
+    elif [ "$STATUS" = pending -a ! "$EXPIRE" -ge "$_DATE" ]; then
+      # omit expired invitations from output
+      :
+    else
+      printf '%s       %s      %s      %s      %s      %s      %i      %s      %s\n' \
+             "$UID_" "$UNAME" "$STATUS" "$EMAIL" "$PWSALT" "$PWHASH" \
+             "$EXPIRE" "$DEVICES" "$FUTUREUSE"
+    fi
+    done <"$user_db" >"${user_db}.$$"
+    mv -- "${user_db}.$$" "$user_db"
+    RELEASE "$user_db"
+  else
+    return 1
+  fi
+}
+
+new_user(){
+  local user="${1:-$(timeid)}"
+  shift 1
+
+  if LOCK "$user_db"; then
+    if grep -q "^${user}       " "$user_db"; then
+      RELEASE "$user_db"
+      return 1
+    fi
+    printf '%s \\      %s      \\      \\      \\      %i      \\      \\\n' \
+           "$user" "pending" "$(( _DATE + USER_CONFIRMEXPIRE ))" >>"$user_db"
+  else
+    return 1
+  fi
+
+  if [ $# -eq 0 ]; then
+    RELEASE "$user_db"
+    return 0
+  elif update_user "$user" "$@"; then
+    return 0
+  else
+    RELEASE "$user_db"
+    return 1
+  fi
+}
+
+user_idmap(){
+  local uid="$1" ret
+  eval "$LOCAL_USER"
+
+  if [ ! "$USER_IDMAP" ]; then
+    while read_user; do
+      USER_IDMAP="${USER_IDMAP}${USER_ID}      ${USER_NAME}${BR}"
+    done <"$user_db"
+  fi
+  if [ "$uid" -a "$USER_IDMAP" != "${USER_IDMAP##*${uid}       }" ]; then
+    ret="${USER_IDMAP##*${uid} }"; ret="${ret%%${BR}*}";
+    printf '%s\n' "$ret"
+    return 0
+  elif [ "$uid" ]; then
+    return 1
+  else
+    printf '%s' "$USER_IDMAP"
+    return 0
+  fi
+}
+
+user_idof(){
+  local name="$(STRING "$1")" ret
+  [ "$USER_IDMAP" ] || user_idmap >/dev/null
+
+  if [ "${name%\\}" -a "$USER_IDMAP" != "${USER_IDMAP% ${name}${BR}*}" ]; then
+    ret="${USER_IDMAP% ${name}${BR}*}"; ret="${ret##*${BR}}"
+    printf '%s\n' "$ret"
+    return 0
+  else
+    return 1
+  fi
+}
+
+user_checkname(){
+  { [ $# -gt 0 ] && printf %s "$*" || cat; } \
+  | sed -nE '
+    :X; $!{N;bX;}
+    s;[ \t\r\n]+; ;g;
+    s;^ ;;; s; $;;;
+    /@/d;
+    /^[a-zA-Z][a-zA-Z0-9 -~]{2,127}$/!d;
+    p;
+    '
+}
+
+user_checkemail(){
+  { [ $# -gt 0 ] && printf %s "$*" || cat; } \
+  | sed -nE '
+    # W3C recommended email regex
+    # https://html.spec.whatwg.org/multipage/input.html#email-state-(type=email)
+    /^[a-zA-Z0-9.!#$%&'\''*+\/=?^_`{|}~-]+@[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/p;
+    '
+}
+
+user_nameexist(){
+  local uname="$(STRING "$1")"
+  local UID_   UNAME   STATUS  EMAIL   PWSALT  PWHASH  EXPIRE  DEVICES FUTUREUSE
+  [ -f "$user_db" -a -r "$user_db" ] \
+  && while read -r UID_        UNAME   STATUS  EMAIL   PWSALT  PWHASH  EXPIRE  DEVICES FUTUREUSE; do
+    [ "$EXPIRE" -gt "$_DATE" -a "$UNAME" = "$uname" ] && return 0
+  done <"$user_db"
+  return 1
+}
+
+user_emailexist(){
+  local email="$(STRING "$1")"
+  local UID_   UNAME   STATUS  EMAIL   PWSALT  PWHASH  EXPIRE  DEVICES FUTUREUSE
+  [ -f "$user_db" -a -r "$user_db" ] \
+  && while read -r UID_        UNAME   STATUS  EMAIL   PWSALT  PWHASH  EXPIRE  DEVICES FUTUREUSE; do
+    [ "$EXPIRE" -gt "$_DATE" -a "$EMAIL" = "$email" ] && return 0
+  done <"$user_db"
+  return 1
+}
+
+user_pwhash(){
+  local salt="$1" secret="$2" hash
+  hash="$(printf '%s\n%s\n' "$secret" "$salt" |sha256sum)"
+  printf '%s\n' "${hash%% *}"
+}
+
+user_register_email() {  # TRANSLATION
+  "$SENDMAIL" -t -f "$MAILFROM" <<-EOF
+       From: ${MAILFROM}
+       To: ${email}
+       Subject: Your account registration at ${HTTP_HOST%:*}
+
+       Someone tried to sign up for a user account using this email address.
+
+       You can activate your account using this link:
+
+           ${SCHEMA}://${HTTP_HOST}${_BASE}${PATH_INFO}?user_confirm=${uid}+$(session_mac "$uid")
+
+       This registration link will expire after $((USER_CONFIRMEXPIRE / 3600)) hours.
+
+       If you did not request an account at ${HTTP_HOST%:*}, then someone else
+       probably entered your email address by accident. In this case you shoud
+       simply ignore this message and we will remove your email address from
+       our database within the next day.
+
+       This is an automatic email. Any direct reply will not be received.
+       Your Account Registration Robot.
+       EOF
+}
+
+user_register(){
+  # reserve account, send registration mail
+  # preliminary uid, expiration, signature
+  local uid="$(timeid)"
+  local uname="$(POST uname |user_checkname)"
+  local email="$(POST email |user_checkemail)"
+  local pwsalt="$(randomid)"
+  local pw="$(POST pw |grep -m1 -xE '.{6,}' )" pwconfirm="$(POST pwconfirm)"
+
+  if [ "$USER_REGISTRATION" != true -a -s "$user_db" ]; then
+    REDIRECT "${_BASE}${PATH_INFO}#ERROR_REGISTRATION_DISABLED"
+  fi
+
+  if   [ "$USER_REQUIREEMAIL" = true ]; then
+    if [ ! "$email" ]; then
+      REDIRECT "${_BASE}${PATH_INFO}#ERROR_EMAIL_INVALID"
+    elif user_emailexist "$email"; then
+      REDIRECT "${_BASE}${PATH_INFO}#ERROR_EMAIL_EXISTS"
+    elif new_user "$uid" status=pending email="$email" expire="$((_DATE + USER_CONFIRMEXPIRE))"; then
+      debug "Sending Activation Link:" \
+            "${SCHEMA}://${HTTP_HOST}${_BASE}${PATH_INFO}?user_confirm=${uid}+$(session_mac "$uid")"
+      user_register_email
+      REDIRECT "${_BASE}${PATH_INFO}#USER_REGISTER_CONFIRM"
+    else
+      REDIRECT "${_BASE}${PATH_INFO}#ERROR_USER_NOLOCK"
+    fi
+
+  elif [ "$USER_REQUIREEMAIL" != true ]; then
+    if [ ! "$uname" ]; then
+      REDIRECT "${_BASE}${PATH_INFO}#ERROR_UNAME_INVALID"
+    elif user_nameexist "$uname"; then
+      REDIRECT "${_BASE}${PATH_INFO}#ERROR_UNAME_EXISTS"
+    elif [ ! "$pw" ]; then
+      REDIRECT "${_BASE}${PATH_INFO}#ERROR_PW_EMPTYTOOSHORT"
+    elif [ "$pw" != "$pwconfirm" ]; then
+      REDIRECT "${_BASE}${PATH_INFO}#ERROR_PW_MISMATCH"
+    elif new_user "$uid" uname="$uname" status=active email="$email" password="$pw" expire="$((_DATE + USER_ACCOUNTEXPIRE))"; then
+      SESSION_COOKIE new
+      SESSION_BIND user_id "$uid"
+
+      if [ "$USER_ACCOUNTPAGE" ]; then
+        REDIRECT "${USER_ACCOUNTPAGE}"
+      else
+        REDIRECT "${_BASE}${PATH_INFO}#USER_REGISTER_CONFIRM"
+      fi
+    else
+      REDIRECT "${_BASE}${PATH_INFO}#ERROR_USER_NOLOCK"
+    fi
+  fi
+}
+
+user_invite_email(){  # TRANSLATION
+  "$SENDMAIL" -t -f "$MAILFROM" <<-EOF
+       From: ${MAILFROM}
+       To: ${email}
+       Subject: You have been invited to ${HTTP_HOST%:*}
+
+       ${USER_NAME:-Someone} has offered an invitation to this email address.
+
+       ${message}
+
+       You can create your account using this link:
+
+           ${SCHEMA}://${HTTP_HOST}${_BASE}${PATH_INFO}?user_confirm=${uid}+$(session_mac "$uid")
+
+       This registration link will expire after $((USER_CONFIRMEXPIRE / 3600)) hours.
+
+       If you do not know what this is about, then someone else probably
+       entered your email address by accident. In this case you shoud
+       simply ignore this message and we will remove your email address from
+       our database within the next day.
+
+       This is an automatic email. Any direct reply will not be received.
+       Your Account Registration Robot.
+       EOF
+}
+
+user_invite(){
+  local uid="$(timeid)"
+  local email="$(POST email |user_checkemail)"
+  local message="$(POST message)"
+
+  if [ ! "$email" ]; then
+    REDIRECT "${_BASE}${PATH_INFO}#ERROR_EMAIL_INVALID"
+  elif user_emailexist "$email"; then
+    REDIRECT "${_BASE}${PATH_INFO}#ERROR_EMAIL_EXISTS"
+  elif new_user "$uid" status=pending email="$email" expire="$((_DATE + USER_CONFIRMEXPIRE))"; then
+    debug "Sending Invitation Link:" \
+          "${SCHEMA}://${HTTP_HOST}${_BASE}${PATH_INFO}?user_confirm=${uid}+$(session_mac "$uid")"
+    user_invite_email
+    REDIRECT "${_BASE}${PATH_INFO}#USER_REGISTER_CONFIRM"
+  else
+    REDIRECT "${_BASE}${PATH_INFO}#ERROR_USER_NOLOCK"
+  fi
+}
+
+user_confirm(){
+  # enable account
+  eval "$LOCAL_USER"
+  local uid="$(POST uid |checkid || printf invalid)"
+  local signature="$(POST signature)"
+  local uname="$(POST uname |user_checkname)"
+  local pwsalt="$(randomid)"
+  local pw="$(POST pw |grep -m1 -xE '.{6,}' )" pwconfirm="$(POST pwconfirm)"
+
+  read_user "${uid}"
+
+  if [ "$signature" != "$(session_mac "$uid")" ]; then
+    REDIRECT "${_BASE}${PATH_INFO}?${QUERY_STRING}#ERROR_LINK_INVALID"
+  elif [ ! "$uname" ]; then
+    REDIRECT "${_BASE}${PATH_INFO}?${QUERY_STRING}#ERROR_UNAME_INVALID"
+  elif user_nameexist "$uname"; then
+    REDIRECT "${_BASE}${PATH_INFO}?${QUERY_STRING}#ERROR_UNAME_EXISTS"
+  elif [ ! "$pw" ]; then
+    REDIRECT "${_BASE}${PATH_INFO}?${QUERY_STRING}#ERROR_PW_EMPTYTOOSHORT"
+  elif [ "$pw" != "$pwconfirm" ]; then
+    REDIRECT "${_BASE}${PATH_INFO}?${QUERY_STRING}#ERROR_PW_MISMATCH"
+  elif [ "$USER_STATUS" != pending -o \! "$USER_EXPIRE" -gt "$_DATE" ]; then
+    REDIRECT "${_BASE}${PATH_INFO}?${QUERY_STRING}#ERROR_LINK_INVALID"
+  elif update_user "$USER_ID" uname="$uname" status=active password="$pw"; then
+    SESSION_COOKIE new
+    SESSION_BIND user_id "$USER_ID"
+    if [ "$USER_ACCOUNTPAGE" ]; then
+      REDIRECT "${USER_ACCOUNTPAGE}"
+    else
+      REDIRECT "${_BASE}${PATH_INFO}?user_register=confirm#USER_REGISTER_CONFIRM"
+    fi
+  else
+    REDIRECT "${_BASE}${PATH_INFO}#ERROR_USER_NOLOCK"
+  fi
+}
+
+user_login(){
+  # set cookie
+  # keep logged in - device cookie?
+  # initialize new session!
+  local UID_   UNAME   STATUS  EMAIL   PWSALT  PWHASH  EXPIRE  DEVICES FUTUREUSE
+  local uname="$(POST uname |STRING)" pw="$(POST pw)"
+
+  [ -f "$user_db" -a -r "$user_db" ] \
+  && while read -r UID_        UNAME   STATUS  EMAIL   PWSALT  PWHASH  EXPIRE  DEVICES FUTUREUSE; do
+    if [ "$UNAME" = "$uname" -o "$EMAIL" = "$uname" ]; then
+      if [ "$STATUS" = active -a "$EXPIRE" -gt "$_DATE" -a "$PWHASH" = "$(user_pwhash "$PWSALT" "$pw")" ]; then
+        SESSION_COOKIE new
+        SESSION_BIND user_id "$UID_"
+        REDIRECT "${_BASE}${PATH_INFO}#USER_LOGGED_IN"
+      fi
+    fi
+  done <"$user_db"
+  REDIRECT "${_BASE}${PATH_INFO}#ERROR_INVALID_LOGIN"
+}
+
+user_logout(){
+  # destroy cookie, destroy session
+  # keep device cookie
+  new_session
+  SESSION_COOKIE new
+  SET_COOKIE 0 user_id="" Path="/${_BASE#/}" SameSite=Strict HttpOnly
+  REDIRECT "${_BASE}${PATH_INFO}#USER_LOGGED_OUT"
+}
+
+user_update(){
+  # todo: username update, email update / email confirm
+  local UID_   UNAME   STATUS  EMAIL   PWSALT  PWHASH  EXPIRE  DEVICES FUTUREUSE
+  # local uname="$(POST uname |STRING)"
+  local uid oldpw pw pwconfirm
+
+        uid="$(POST uid)"
+      oldpw="$(POST oldpw)"
+         pw="$(POST pw |grep -m1 -xE '.{6,}')"
+  pwconfirm="$(POST pwconfirm)"
+
+
+  read -r UID_ UNAME   STATUS  EMAIL   PWSALT  PWHASH  EXPIRE  DEVICES FUTUREUSE <<-EOF
+       $(grep "^${uid} " "$user_db")
+       EOF
+
+  if [ "$UID_" = "$USER_ID" -a "$PWHASH" = "$(user_pwhash "$PWSALT" "$oldpw")" ]; then
+    if [ "$pw" -a "$pw" = "$pwconfirm" ]; then
+      update_user "${uid}" password="$pw"
+      REDIRECT "${_BASE}${PATH_INFO}#UPDATE_SUCCESS"
+    else
+      REDIRECT "${_BASE}${PATH_INFO}#ERROR_PW_MISMATCH"
+    fi
+  elif [ "$UID_" = "$USER_ID" ]; then
+    REDIRECT "${_BASE}${PATH_INFO}#ERROR_INVALID_AUTH_PASSWORD"
+  else
+    REDIRECT "${_BASE}${PATH_INFO}#ERROR_NOTLOGGEDIN"
+  fi
+}
+
+user_recover(){
+  # send recover link
+  :
+}
+user_disable(){
+  :
+}
+
+read_user "$(SESSION_VAR user_id)"
+[ "$USER_STATUS" -a "$USER_STATUS" != active ] && eval $UNSET_USER
+
+[ "$REQUEST_METHOD" = POST ] && case "$(POST action)" in
+  user_register) user_register ;;
+  user_confirm)  user_confirm ;;
+  user_invite)   user_invite ;;
+  user_login)    user_login ;;
+  user_logout)   user_logout ;;
+  user_update)   user_update ;;
+  user_recover)
+    :;;
+  user_disable)
+    :;;
+esac
+
+export USER_ID USER_NAME USER_STATUS USER_EMAIL USER_PWSALT USER_PWHASH \
+       USER_EXPIRE USER_DEVICES USER_FUTUREUSE
+
+
+w_user_update(){
+  if [ ! "$USER_ID" ]; then
+    cat <<-EOF
+       [div #user_update .nouser
+       This page can only be used by registered users
+       ]
+       EOF
+  else
+    cat <<-EOF
+       [form #user_update method=POST
+         [hidden "uid" "$USER_ID"]
+         [p .username Logged in as $USER_NAME]
+         [input type=password name=oldpw placeholder="Current Passphrase"]
+         [input type=password name=pw placeholder="New Passphrase" pattern=".{6,}"]
+         [input type=password name=pwconfirm placeholder="Confirm New Passphrase" pattern=".{6,}"]
+         [submit "action" "user_update" Update Passphrase]
+       ]
+       EOF
+  fi
+}
+
+w_user_register_disabled(){  # TRANSLATION
+  cat <<-EOF
+       [div #user_register .disabled
+       User Registration is disabled.
+       ]
+       EOF
+}
+w_user_register_sendmail(){  # TRANSLATION
+  cat <<-EOF
+       [form #user_register .registeremail method=POST
+         [p We will send an activation mail to your email address.
+           You can continue the signup process when you click on the
+           activation link in this email.]
+         [input type=email name=email placeholder="Email"]
+         [submit "action" "user_register" Sign Up]
+       ]
+       EOF
+}
+w_user_register_direct(){  # TRANSLATION
+  cat <<-EOF
+       [form #user_register .registername method=POST
+          [input name=uname placeholder="Choose Username" tooltip="Your username may contain any character but the @ sign. It must be at least 3 characters long, and it must start with a letter." pattern="^\[\\\\p{L}\]\[\\\\p{L}0-9 -~\]{2,127}$" autocomplete=off]
+         [input type=password name=pw placeholder="Choose Passphrase" pattern=".{6,}"]
+         [input type=password name=pwconfirm placeholder="Confirm Passphrase" pattern=".{6,}"]
+         [submit "action" "user_register" Sign Up]
+       ]
+       EOF
+}
+
+w_user_register(){
+  if [ "$(GET user_confirm)" ]; then
+    w_user_confirm
+  elif [ "$USER_REGISTRATION" != true -a -s "$user_db" ]; then
+    w_user_register_disabled
+  elif [ "$USER_REQUIREEMAIL" = true ]; then
+    w_user_register_sendmail
+  elif [ "$USER_REQUIREEMAIL" != true ]; then
+    w_user_register_direct
+  fi
+}
+
+w_user_confirm_proceed(){  # TRANSLATION
+  cat <<-EOF
+       [form #user_confirm method=POST
+         [input type=hidden name=uid value="${uid}"]
+         [input type=hidden name=signature value="${signature}"]
+         $([ "$EMAIL" != '\' ] && printf \
+           '[input disabled=disabled value="%s" placeholder="Email"]' "$(UNSTRING "$EMAIL" |HTML)"
+         )
+          [input name=uname placeholder="Choose Username" tooltip="Your username may contain any character but the @ sign. It must be at least 3 characters long, and it must start with a letter." pattern="^\[\\\\p{L}\]\[\\\\p{L}0-9 -~\]{2,127}$" autocomplete=off]
+         [input type=password name=pw placeholder="Choose Passphrase" pattern=".{6,}"]
+         [input type=password name=pwconfirm placeholder="Confirm Passphrase" pattern=".{6,}"]
+         [submit "action" "user_confirm" Finish Registration]
+       ]
+       EOF
+}
+w_user_confirm_expired(){  # TRANSLATION
+  cat <<-EOF
+       [div #user_confirm .expired
+         [p This activation link is not valid anymore.]
+       ]
+       EOF
+}
+w_user_confirm_invalid(){  # TRANSLATION
+  cat <<-EOF
+       [div #user_confirm .invalid
+         [p This activation link is invalid. Make sure you copied the whole activation link from your email and be careful not to include any line breaks.]
+       ]
+       EOF
+}
+
+w_user_confirm(){
+  local UID_   UNAME   STATUS  EMAIL   PWSALT  PWHASH  EXPIRE  DEVICES FUTUREUSE
+  local user_confirm="$(GET user_confirm)"
+  local uid="${user_confirm% *}" signature="${user_confirm#* }"
+
+  if [ "$signature" = "$(session_mac "$uid")" ]; then
+    read -r UID_       UNAME   STATUS  EMAIL   PWSALT  PWHASH  EXPIRE  DEVICES FUTUREUSE <<-EOF
+       $(grep "^${uid} " "$user_db")
+       EOF
+    if [ "$STATUS" = pending -a "$EXPIRE" -gt "$_DATE" ]; then
+      w_user_confirm_proceed
+    else
+      w_user_confirm_expired
+    fi
+  else
+    w_user_confirm_invalid
+  fi
+}
+
+w_user_invite_email(){  # TRANSLATION
+  cat <<-EOF
+       [form #user_invite method=POST
+         [input placeholder="Email Recipient" name=email autocomplete=off]
+         [textarea name="message" placeholder="Message to recipient" . ]
+         [submit "action" "user_invite" Send Invitation]
+       ]
+       EOF
+}
+w_user_invite_link(){  # TRANSLATION
+  cat <<-EOF
+       [div #user_invite .link
+          [p An anonymous user account has been set up. Send the following link to the intended user, so they may claim their account. The link will remain valid for $((USER_CONFIRMEXPIRE / 3600)) hours.]
+          [a href="$(HTML "$invlink")" . $(HTML "$invlink")]
+
+          [p [a href="#" . Set up another account]]
+       ]
+       EOF
+}
+w_user_invite_deny(){  # TRANSLATION
+  cat <<-EOF
+       [div #user_invite .notallowed
+         Only registered users may send an invitation to another user.
+       ]
+       EOF
+}
+
+w_user_invite(){
+  local uid invlink
+
+  if [ "$(GET user_confirm)" ]; then
+    w_user_confirm
+  elif [ "$USER_ID" -a "$USER_REQUIREEMAIL" = true ]; then
+    w_user_invite_email
+  elif [ "$USER_ID" ]; then
+    uid="$(timeid)"
+    new_user "$uid" status=pending expire="$((_DATE + USER_CONFIRMEXPIRE))"
+    invlink="${SCHEMA}://${HTTP_HOST}${_BASE}${PATH_INFO}?user_confirm=${uid}+$(session_mac "$uid")"
+    debug "New Invitation Link: $invlink"
+    w_user_invite_link
+  else
+    w_user_invite_deny
+  fi
+}
+
+w_user_login_logon(){  # TRANSLATION
+  cat <<-EOF
+       [form #user_login .login method=POST
+         [input name=uname placeholder="Username or Email"]
+         [input type=password name=pw placeholder="Passphrase"]
+         [submit "action" "user_login" Login]
+       ]
+       EOF
+}
+w_user_login_logoff(){  # TRANSLATION
+  cat <<-EOF
+       [form #user_login .logout method=POST
+         [p Logged in as [span . $(HTML ${USER_NAME})]]
+         [submit "action" "user_logout" Logout]
+       ]
+       EOF
+}
+
+w_user_login(){
+  if [ ! "$USER_ID" ]; then
+    w_user_login_logon
+  elif [ "$USER_ID" ]; then
+    w_user_login_logoff
+  fi
+}