summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Bütow <snonux@users.noreply.github.com>2021-05-21 05:12:35 +0100
committerGitHub <noreply@github.com>2021-05-21 05:12:35 +0100
commitb2d00167fa2df42808e2c95cba47d28279ce81cd (patch)
tree35b44d3a2795f8064e12562d69d226d128f79561
parent7868506c413b453c8c1935f953a0e35a0b4c4ef9 (diff)
parent9dbd7f008192fd506bf642944232334fad0ed55c (diff)
Merge pull request #1 from snonux/master
Master
-rw-r--r--.gitmodules0
-rw-r--r--README.md110
-rw-r--r--buetow.org.conf9
-rwxr-xr-xbuetow.org.sh107
-rw-r--r--footer.html.part2
-rw-r--r--header.html.part50
-rw-r--r--packages/assert.source.sh59
-rw-r--r--packages/atomfeed.source.sh128
-rw-r--r--packages/gemfeed.source.sh54
-rw-r--r--packages/generate.source.sh160
-rw-r--r--packages/git.source.sh50
-rw-r--r--packages/html.source.sh162
-rw-r--r--packages/log.source.sh30
-rw-r--r--packages/md.source.sh63
14 files changed, 984 insertions, 0 deletions
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/.gitmodules
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e162309
--- /dev/null
+++ b/README.md
@@ -0,0 +1,110 @@
+The buetow.org internet site
+============================
+
+This is the source code of my personal internet site and blog engine. All content is written in Gemini Gemtext format, but the script `buetow.org.sh` generates multiple other static output formats from it. You can reach the site(s)...
+
+* Via Gemini/Gemtext: [gemini://buetow.org](gemini://buetow.org) (You need a Gemini client for this)
+* Via "normal" HTML: [https://buetow.org](https://buetow.org) (Actually it's XHTML Transitional 1.0)
+* Via [Gemini Webproxy](https://portal.mozz.us/gemini/buetow.org)
+* Via [GitHub Markdown](https://github.com/snonux/buetow.org/blob/content-md/index.md)
+* Via [GitHub Page](https://snonux.github.io/buetow.org) (from Markdown)
+
+Have a look at the `content-*` branches of this buetow.org Git project for source samples.
+
+## Software I use to maintain this site
+
+* Text editor: [Vim](https://www.vim.org)
+* Gemini server: [a-h/gemini](https://github.com/a-h/gemini)
+* Bash
+* ShellCheck (if you want to run the tests)
+* Web server: [Apache HTTPD](https://httpd.apache.org) (for "normal" HTML site)
+* Obviously, I am also using GitHub for source code and GitHub Page hosting.
+
+# Getting started
+
+## Requirements
+
+These are the requirements of the `buetow.org.sh` static site generator script:
+
+* GNU Bash 5.x or higher
+* ShellCheck installed
+* GNU Sed
+* GNU Date
+* Git
+
+The script was tested on a recent Fedora Linux. For *BSD or macOS you would need to install GNU Sed, GNU Date, and a newer version of Bash.
+
+## Usage
+
+So you want such a pretty internet site too?
+
+To get started, just clone this repo (master branch) and run `./buetow.org.sh`. You will will be prompted with further instructions.
+
+You will notice soon, that all site content is located in `../buetow.org-content/` (you can configure the `BASE_CONTENT_DIR` in `buetow.org.conf`). There is one sub-directory per output format, e.g.:
+
+```
+../buetow.org-content/gemtext
+../buetow.org-content/html
+../buetow.org-content/md
+../buetow.org-content/meta
+```
+
+### Alternative config file path
+
+If you don't want to mess with `buetow.org.conf`, you can use an alternative config file path in `~/.config/buetow.org.conf`, which takes precedence if it exists. Another way is to set the `CONFIG_FILE_PATH` environment variable, e.g.:
+
+```
+export CONFIG_FILE_PATH=~/.config/my-site.geek.conf
+./buetow.org.sh --generate
+```
+
+### What is what
+
+Whereas, you only want to directly edit/add/remove content in the `gemtext` folder. The `buetow.org.sh` then will take the Gemtext and update all other formats accordingly. Summary of what is what:
+
+* `gemtext`: The Gemini Gemtext markup files of the internet site.
+* `html`: The XHTML version of it.
+* `md`: The Markdown version of it.
+* `meta`: Some meta data of all Gemtext blog posts. It's used by `buetow.org.sh` internally for Atom feed generation.
+
+### Special HTML configuration
+
+You will find the `./header.html.part` and `./footer.html.part` files, they are minimal template files for the HTML generation.
+
+### Special Markdown configuraiton
+
+`buetow.org.sh` will never touch the `../buetow.org-content/md/_config.yml` file (if it exists). That's a special configuration file for GitHub Pages.
+
+## Store all formats in Git
+
+I personally have for each directory in `../buetow.org-content/` a separate Git repository configured. So whenever something has changed it will be updated/added/removed to version control. The following will run the generator and commit everything to Git:
+
+```
+USE_GIT=yes ./buetow.org --generate
+```
+
+And the following will additionally perform a `git pull` and `git push` afterwards;
+
+```
+USE_GIT=yes GIT_PUSH=yes ./buetow.org --generate
+```
+
+You could add the `USE_GIT` and `GIT_PUSH` options to the `buetow.org.conf` config file too.
+
+## Publishing a blog post
+
+All what needs to be done is to create a new file in `./gemtext/gemfeed/YYYY-MM-DD-article-title-dash-separated.gmi`, whereas `YYYY-MM-DD` defines the publishing date of the blog post.
+
+A subsequent `./buetow.org.sh --generate` will then detect the new post and link it from `$BASE_CONTENT_DIR/gemtext/gemfeed/index.gmi`, link it from the main index `$BASE_CONTENT_DIR/gemtext/index.gmi`, and also add it to the Atom feed at `$BASE_CONTENT_DIR/gemtext/gemfeed/atom.xml`. The first level 1 Gemtext title (e.g. `# Title`) will be the displayed link name. `YYYY-MM-DD` will be the publishing date. There are various other settings, such as Author - they come from the `buetow.org.conf` configuration file.
+
+Once all of that is done, the `buetow.org.sh` script will convert the new post (plus all the indices and the Atom feed) to the other formats too (e.g. HTML, Markdown).
+
+You can also have a look at `$BASE_CONTENT_DIR/meta/gemfeed`. There is a meta file for each blog post stored. These meta files are required for the generation of the Atom feed. You can edit these meta files manually and run `./buetow.org.sh --generate` or `./buetow.org.sh --feed` again, in case you want to change some of the Atom feed content.
+
+## Finito
+
+After running `./buetow.org --genreate` you will have all static files ready to be published. But before you do that you could preview the content with `firefox ../buetow.org-content/html/index.html` or `glow ../buetow.org-content/md/index.md` (you get the idea).
+
+Have also a look at the generated `atom.xml` files. They make sense (at least) for Gemtext and HTML.
+
+Now it is up to you to setup a Gemini server for the Gemtext, a Webserver for the HTML and/or a GitHub page for the Markdowns.
diff --git a/buetow.org.conf b/buetow.org.conf
new file mode 100644
index 0000000..effeaef
--- /dev/null
+++ b/buetow.org.conf
@@ -0,0 +1,9 @@
+declare -xr DOMAIN=buetow.org
+declare -xr SUBTITLE='Having fun with computers!'
+declare -xr AUTHOR='Paul Buetow'
+declare -xr EMAIL='comments@mx.buetow.org'
+declare -xr IMAGE_PATTERN='\.(jpg|png|gif)$'
+declare -xr ATOM_MAX_ENTRIES=42
+declare -xr CONTENT_BASE_DIR=../buetow.org-content
+declare -xr HTML_HEADER=./header.html.part
+declare -xr HTML_FOOTER=./header.html.part
diff --git a/buetow.org.sh b/buetow.org.sh
new file mode 100755
index 0000000..29eadbd
--- /dev/null
+++ b/buetow.org.sh
@@ -0,0 +1,107 @@
+#!/usr/bin/env bash
+#
+# The buetow.org.sh static site generator
+# by Paul Buetow 2021
+
+declare -r ARG="$1"; shift
+declare DATE=date
+declare SED=sed
+declare GREP=grep
+which gdate &>/dev/null && DATE=gdate
+which gsed &>/dev/null && SED=gsed
+which ggrep &>/dev/null && GREP=ggrep
+readonly DATE
+readonly SED
+readonly GREP
+
+set -e
+
+if [[ -n "$CONFIG_FILE_PATH" ]]; then
+ source "$CONFIG_FILE_PATH"
+elif [[ -f ~/.config/buetow.org.conf ]]; then
+ source ~/.config/buetow.org.conf
+else
+ source ./buetow.org.conf
+fi
+
+source ./packages/assert.source.sh
+source ./packages/git.source.sh
+source ./packages/atomfeed.source.sh
+source ./packages/gemfeed.source.sh
+source ./packages/generate.source.sh
+source ./packages/html.source.sh
+source ./packages/log.source.sh
+source ./packages/md.source.sh
+
+help () {
+ cat <<HELPHERE
+$0's possible arguments:
+ --feed Generates Gemtext Atom feed and Gemfeed.
+ --generate Generates all known output formats (html, md, ...).
+ If USE_GIT=yes set, all files will be commited to git too.
+ If GIT_PUSH=yes is set, all content will be pushed to origin.
+ --test Only runs some shellcheck and unit tests.
+ --help Prints this retty text.
+Example:
+ USE_GIT=yes GIT_PUSH=yes $0 --generate
+HELPHERE
+}
+
+setup () {
+ if [ ! -d "$CONTENT_BASE_DIR" ]; then
+ cat <<END
+The content base directory, does not exist. Run the following to create it, it
+also adds some sample Gemtext content:
+
+ mkdir -p $CONTENT_BASE_DIR/{meta,md,html}
+ git clone --branch content-gemtext https://github.com/snonux/buetow.org $CONTENT_BASE_DIR/gemtext
+ rm -Rf $CONTENT_BASE_DIR/gemtext/.git
+
+Once done, you are ready to edit the files in $CONTENT_BASE_DIR/gemtext. Every
+time you want to generate other formats from Gemtext (e.g. HTML, Markdown), run
+ $0 --generate
+again.
+
+For a list of other available arguments run
+ $0 --help
+
+Pro tip: You could make all the directories in $CONTENT_BASE_DIR separate git
+repositories or branches. You can then run
+ USE_GIT=yes $0 --generate
+so that all static files are commited to the content repositories too.
+END
+ exit 1
+ fi
+}
+
+main () {
+ local -r arg="$1"; shift
+
+ setup
+
+ case $arg in
+ --test)
+ LOG_VERBOSE=yes
+ assert::shellcheck
+ html::test
+ md::test
+ ;;
+ --feed)
+ gemfeed::generate
+ atomfeed::generate
+ ;;
+ --generate)
+ gemfeed::generate
+ atomfeed::generate
+ generate::fromgmi html md
+ ;;
+ --help|*)
+ help
+ ;;
+ esac
+
+ return 0
+}
+
+main "$ARG"
+exit $?
diff --git a/footer.html.part b/footer.html.part
new file mode 100644
index 0000000..308b1d0
--- /dev/null
+++ b/footer.html.part
@@ -0,0 +1,2 @@
+</body>
+</html>
diff --git a/header.html.part b/header.html.part
new file mode 100644
index 0000000..d499e51
--- /dev/null
+++ b/header.html.part
@@ -0,0 +1,50 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
+<head>
+<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
+<title>%%TITLE%%</title>
+<link rel="shortcut icon" type="image/gif" href="/favicon.ico" />
+<style type="text/css">
+body {
+ margin: auto;
+ max-width: 900px;
+ background-color: #FFFFEF;
+ border: 1px dashed #880000;
+ border-radius: 8px;
+ padding: 5px;
+}
+img {
+ display:block;
+ max-width: 80%;
+}
+a.textlink:before {
+ content: " ⇒ ";
+ padding-left: 2px;
+}
+a.textlink {
+ text-decoration: none;
+ color: #FF0000;
+}
+a.textlink:hover {
+ text-decoration: underline;
+}
+i {
+ color: #48AAAD;
+}
+pre {
+ background-color: #F1F8E9;
+ border: 1px dashed #BB0000;
+ border-radius: 8px;
+ padding: 5px;
+ font-family: "Lucida Console", "Courier New", monospace;
+}
+h1 {
+ text-align: center;
+ color: #880000;
+}
+h2, h3 {
+ color: #BB0000;
+}
+</style>
+</head>
+<body>
diff --git a/packages/assert.source.sh b/packages/assert.source.sh
new file mode 100644
index 0000000..d53a728
--- /dev/null
+++ b/packages/assert.source.sh
@@ -0,0 +1,59 @@
+# Unit test for whether 2 given strings equal.
+assert::equals () {
+ local -r result="$1"; shift
+ local -r expected="$1"; shift
+ local -r callee=${FUNCNAME[1]}
+
+ if [[ "$result" != "$expected" ]]; then
+ cat <<ERROR | log::pipe ERROR
+In $callee expected
+ '$expected'
+But got
+ '$result'
+ERROR
+ exit 2
+ fi
+
+ log VERBOSE "Result in $callee as expected: '$expected'"
+}
+
+# Unit test for whether a given string is not empty.
+assert::not_empty () {
+ local -r name="$1"; shift
+ local -r content="$1"; shift
+ local -r callee=${FUNCNAME[1]}
+
+ if [ -z "$content" ]; then
+ log ERROR "In $callee expected '$name' not to be empty!"
+ exit 2
+ fi
+
+ log VERBOSE "Result in $callee as expected not empty"
+}
+
+# Unit test for whether a given string matches a regex.
+assert::matches () {
+ local -r name="$1"; shift
+ local -r content="$1"; shift
+ local -r regex="$1"; shift
+ local -r callee=${FUNCNAME[1]}
+
+ if ! $GREP -q -E "$regex" <<< "$content"; then
+ log ERROR "In $callee expected '$name' to match '$regex'"
+ exit 2
+ fi
+
+ log VERBOSE "Matching in $callee as expected"
+}
+
+# Checks if all the Bash scripts here are good.
+assert::shellcheck () {
+ set -e
+ shellcheck \
+ --norc \
+ --external-sources \
+ --check-sourced \
+ --exclude=SC2155,SC2010,SC2154,SC1090,SC2012 \
+ ./"$0"
+ set +e
+}
diff --git a/packages/atomfeed.source.sh b/packages/atomfeed.source.sh
new file mode 100644
index 0000000..e1ee406
--- /dev/null
+++ b/packages/atomfeed.source.sh
@@ -0,0 +1,128 @@
+# Retrieve meta data of a given blog post. Generate new meta info if not yet exists.
+atomfeed::meta () {
+ local -r gmi_file_path="$1"; shift
+ local -r meta_file=$($SED 's|gemtext|meta|; s|.gmi$|.meta|;' <<< "$gmi_file_path")
+
+ log VERBOSE "Generating meta info for post $gmi_file_path"
+
+ local is_draft=no
+ if $GREP -E -q '\.draft\.meta$' <<< "$meta_file"; then
+ is_draft=yes
+ fi
+
+ local -r meta_dir=$(dirname "$meta_file")
+ if [[ ! -d "$meta_dir" ]]; then
+ mkdir -p "$meta_dir"
+ fi
+
+ if [ ! -f "$meta_file" ]; then
+ # Extract first heading as post title.
+ local title=$($SED -n '/^# / { s/# //; p; q; }' "$gmi_file_path" | tr '"' "'")
+ # Extract first paragraph from Gemtext
+ local summary=$($SED -n '/^[A-Z]/ { p; q; }' "$gmi_file_path" | tr '"' "'")
+ # Extract the date from the file name.
+ local filename_date=$(basename "$gmi_file_path" | cut -d- -f1,2,3)
+ local date=$($DATE --iso-8601=seconds --date "$filename_date $($DATE +%H:%M:%S)")
+
+ cat <<META | tee "$meta_file"
+local meta_date="$date"
+local meta_author="$AUTHOR"
+local meta_email="$EMAIL"
+local meta_title="$title"
+local meta_summary="$summary. .....to read on please visit my site."
+META
+ if [[ $is_draft == no ]]; then
+ git::add meta "$meta_file"
+ fi
+ return
+ fi
+
+ cat "$meta_file"
+ if [[ $is_draft == yes ]]; then
+ rm "$meta_file"
+ fi
+}
+
+# Retrieve the core content as XHTML of the blog post.
+atomfeed::content () {
+ local -r gmi_file_path="$1"; shift
+ log VERBOSE "Retrieving feed content from $gmi_file_path"
+
+ # sed: Remove all before the first header
+ # sed: Make HTML links absolute, Atom relative URLs feature seems a mess
+ # across different Atom clients.
+ html::fromgmi < <($SED '/Go back to the main site/d' "$gmi_file_path") |
+ $SED "
+ s|href=\"\./|href=\"https://$DOMAIN/gemfeed/|g;
+ s|src=\"\./|src=\"https://$DOMAIN/gemfeed/|g;
+ "
+}
+
+# Generate an atom.xml feed file.
+atomfeed::generate () {
+ local -r gemfeed_dir="$CONTENT_BASE_DIR/gemtext/gemfeed"
+ local -r atom_file="$gemfeed_dir/atom.xml"
+ local -r now=$($DATE --iso-8601=seconds)
+ log INFO "Generating Atom feed to $atom_file"
+
+ assert::not_empty now "$now"
+
+ cat <<ATOMHEADER > "$atom_file.tmp"
+<?xml version="1.0" encoding="utf-8"?>
+<feed xmlns="http://www.w3.org/2005/Atom">
+ <updated>$now</updated>
+ <title>$DOMAIN feed</title>
+ <subtitle>$SUBTITLE</subtitle>
+ <link href="gemini://$DOMAIN/gemfeed/atom.xml" rel="self" />
+ <link href="gemini://$DOMAIN/" />
+ <id>gemini://$DOMAIN/</id>
+ATOMHEADER
+
+ while read -r gmi_file; do
+ # Load cached meta information about the post.
+ source <(atomfeed::meta "$gemfeed_dir/$gmi_file")
+
+ # Get HTML content for the feed
+ local content="$(atomfeed::content "$gemfeed_dir/$gmi_file")"
+
+ assert::not_empty meta_title "$meta_title"
+ assert::not_empty meta_date "$meta_date"
+ assert::not_empty meta_author "$meta_author"
+ assert::not_empty meta_email "$meta_email"
+ assert::not_empty meta_summary "$meta_summary"
+ assert::not_empty content "$content"
+
+ cat <<ATOMENTRY >> "$atom_file.tmp"
+ <entry>
+ <title>$meta_title</title>
+ <link href="gemini://$DOMAIN/gemfeed/$gmi_file" />
+ <id>gemini://$DOMAIN/gemfeed/$gmi_file</id>
+ <updated>$meta_date</updated>
+ <author>
+ <name>$meta_author</name>
+ <email>$meta_email</email>
+ </author>
+ <summary>$meta_summary</summary>
+ <content type="xhtml">
+ <div xmlns="http://www.w3.org/1999/xhtml">
+ $content
+ </div>
+ </content>
+ </entry>
+ATOMENTRY
+ done < <(gemfeed::get_posts | head -n $ATOM_MAX_ENTRIES)
+
+ cat <<ATOMFOOTER >> "$atom_file.tmp"
+</feed>
+ATOMFOOTER
+
+ # Delete the 3rd line of the atom feeds (global feed update timestamp)
+ if ! diff -u <($SED 3d "$atom_file") <($SED 3d "$atom_file.tmp"); then
+ log INFO 'Feed got something new!'
+ mv "$atom_file.tmp" "$atom_file"
+ git::add gemtext "$atom_file"
+ else
+ log INFO 'Nothing really new in the feed'
+ rm "$atom_file.tmp"
+ fi
+}
diff --git a/packages/gemfeed.source.sh b/packages/gemfeed.source.sh
new file mode 100644
index 0000000..c842bb1
--- /dev/null
+++ b/packages/gemfeed.source.sh
@@ -0,0 +1,54 @@
+# Filter out blog posts from other files in the gemfeed dir.
+gemfeed::get_posts () {
+ local -r gemfeed_dir="$CONTENT_BASE_DIR/gemtext/gemfeed"
+ local -r gmi_pattern='^[0-9]{4}-[0-9]{2}-[0-9]{2}-.*\.gmi$'
+ local -r draft_pattern='\.draft\.gmi$'
+
+ ls "$gemfeed_dir" |
+ $GREP -E "$gmi_pattern" |
+ $GREP -E -v "$draft_pattern" |
+ sort -r
+}
+
+# Add the links from gemfeed/index.gmi to the main index site.
+gemfeed::updatemainindex () {
+ local -r index_gmi="$CONTENT_BASE_DIR/gemtext/index.gmi"
+ local -r gemfeed_dir="$CONTENT_BASE_DIR/gemtext/gemfeed"
+
+ log VERBOSE "Updating $index_gmi with posts from $gemfeed_dir"
+
+ # Remove old gemfeeds from main index
+ $SED '/^=> .\/gemfeed\/[0-9].* - .*/d;' "$index_gmi" > "$index_gmi.tmp"
+ # Add current gemfeeds to main index
+ $SED -n '/^=> / { s| ./| ./gemfeed/|; p; }' "$gemfeed_dir/index.gmi" >> "$index_gmi.tmp"
+
+ mv "$index_gmi.tmp" "$index_gmi"
+ git::add gemtext "$index_gmi"
+}
+
+# Generate a index.gmi in the ./gemfeed subdir.
+gemfeed::generate () {
+ local -r gemfeed_dir="$CONTENT_BASE_DIR/gemtext/gemfeed"
+ log INFO "Generating Gemfeed index for $gemfeed_dir"
+
+cat <<GEMFEED > "$gemfeed_dir/index.gmi.tmp"
+# $DOMAIN's Gemfeed
+
+## $SUBTITLE
+
+GEMFEED
+
+ gemfeed::get_posts | while read -r gmi_file; do
+ # Extract first heading as post title.
+ local title=$($SED -n '/^# / { s/# //; p; q; }' "$gemfeed_dir/$gmi_file" | tr '"' "'")
+ # Extract the date from the file name.
+ local filename_date=$(basename "$gemfeed_dir/$gmi_file" | cut -d- -f1,2,3)
+
+ echo "=> ./$gmi_file $filename_date - $title" >> "$gemfeed_dir/index.gmi.tmp"
+ done
+
+ mv "$gemfeed_dir/index.gmi.tmp" "$gemfeed_dir/index.gmi"
+ git::add gemtext "$gemfeed_dir/index.gmi"
+
+ gemfeed::updatemainindex
+}
diff --git a/packages/generate.source.sh b/packages/generate.source.sh
new file mode 100644
index 0000000..98cd215
--- /dev/null
+++ b/packages/generate.source.sh
@@ -0,0 +1,160 @@
+# Generate a HTML or Markdown link from given Gemtext link.
+generate::make_link () {
+ local -r what="$1"; shift
+ local -r line="${1/=> }"; shift
+ local link
+ local descr
+
+ while read -r token; do
+ if [ -z "$link" ]; then
+ link="$token"
+ elif [ -z "$descr" ]; then
+ descr="$token"
+ else
+ descr="$descr $token"
+ fi
+ done < <(echo "$line" | tr ' ' '\n')
+
+ if $GREP -E -q "$IMAGE_PATTERN" <<< "$link"; then
+ if [[ "$what" == md ]]; then
+ md::make_img "$link" "$descr"
+ else
+ html::make_img "$link" "$(html::encode "$descr")"
+ fi
+ return
+ fi
+
+ if [[ "$what" == md ]]; then
+ md::make_link "$link" "$descr"
+ else
+ html::make_link "$link" "$(html::encode "$descr")"
+ fi
+}
+
+# Add other docs (e.g. images, videos) from Gemtext to output format.
+generate::fromgmi_add_docs () {
+ local -r src="$1"; shift
+ local -r format="$1"; shift
+ local -r dest=${src/gemtext/$format}
+ local -r dest_dir=$(dirname "$dest")
+
+ if [[ ! -d "$dest_dir" ]]; then
+ mkdir -p "$dest_dir"
+ fi
+ cp "$src" "$dest"
+ git::add "$format" "$dest"
+}
+
+# Remove docs from output format which aren't present in Gemtext anymore.
+generate::fromgmi_cleanup_docs () {
+ local -r src="$1"; shift
+ local -r format="$1"; shift
+ local dest=${src/.$format/.gmi}
+ dest=${dest/$format/gemtext}
+
+ if [[ ! -f "$dest" ]]; then
+ git::rm "$format" "$src"
+ fi
+}
+
+# Convert the Gemtext Atom feed to a HTML Atom feed.
+generate::convert_gmi_atom_to_html_atom () {
+ local -r format="$1"; shift
+ if [[ "$format" != html ]]; then
+ return
+ fi
+
+ log INFO 'Converting Gemtext Atom feed to HTML Atom feed'
+
+ $SED 's|.gmi|.html|g; s|gemini://|https://|g' \
+ < $CONTENT_BASE_DIR/gemtext/gemfeed/atom.xml \
+ > $CONTENT_BASE_DIR/html/gemfeed/atom.xml
+
+ git::add "$format" "$CONTENT_BASE_DIR/html/gemfeed/atom.xml"
+}
+
+# Internal helper function for generate::fromgmi
+generate::_fromgmi () {
+ local -r src="$1"; shift
+ local -r format="$1"; shift
+ local dest=${src/gemtext/$format}
+ dest=${dest/.gmi/.$format}
+ local dest_dir=$(dirname "$dest")
+
+ if [[ ! -d "$dest_dir" ]]; then
+ mkdir -p "$dest_dir"
+ fi
+
+ if [[ "$format" == html ]]; then
+ cat "$HTML_HEADER" > "$dest.tmp"
+ html::fromgmi < "$src" >> "$dest.tmp"
+ cat "$HTML_FOOTER" >> "$dest.tmp"
+
+ elif [[ "$format" == md ]]; then
+ md::fromgmi < "$src" >> "$dest.tmp"
+ fi
+
+ local title=$($SED -n '/^# / { s/# //; p; q; }' "$src" | tr '"' "'")
+ if [[ -z "$title" ]]; then
+ title=$SUBTITLE
+ fi
+ $SED -i "s|%%TITLE%%|$title|g" "$dest.tmp"
+ mv "$dest.tmp" "$dest"
+
+ git::add "$format" "$dest"
+}
+
+# Generate a given output format from a Gemtext file.
+generate::fromgmi () {
+ local -i num_gmi_files=0
+ local -i num_doc_files=0
+
+ log INFO "Generating $* from Gemtext"
+
+ while read -r src; do
+ num_gmi_files=$(( num_gmi_files + 1 ))
+ for format in "$@"; do
+ generate::_fromgmi "$src" "$format"
+ done
+ done < <(find "$CONTENT_BASE_DIR/gemtext" -type f -name \*.gmi)
+
+ log INFO "Converted $num_gmi_files Gemtext files"
+
+ # Add non-.gmi files to html dir.
+ log VERBOSE "Adding other docs to $*"
+
+ while read -r src; do
+ num_doc_files=$(( num_doc_files + 1 ))
+ for format in "$@"; do
+ generate::fromgmi_add_docs "$src" "$format"
+ done
+ done < <(find "$CONTENT_BASE_DIR/gemtext" -type f | $GREP -E -v '(\.git.*|\.gmi|atom.xml|\.tmp)$')
+
+ log INFO "Added $num_doc_files other documents to each of $*"
+
+ # Add atom feed for HTML
+ for format in "$@"; do
+ generate::convert_gmi_atom_to_html_atom "$format"
+ done
+
+ # Remove obsolete files from ./html/.
+ # Note: The _config.yml is the config file for GitHub pages (md format).
+ for format in "$@"; do
+ find "$CONTENT_BASE_DIR/$format" -type f |
+ $GREP -E -v '(\.git.*|_config.yml)$'|
+ while read -r src; do
+ generate::fromgmi_cleanup_docs "$src" "$format"
+ done
+ done
+
+ if [[ -z "$GIT_COMMIT_MESSAGE" ]]; then
+ GIT_COMMIT_MESSAGE='Publishing new version'
+ fi
+ git::commit gemtext "$GIT_COMMIT_MESSAGE"
+ git::commit meta "$GIT_COMMIT_MESSAGE"
+
+ for format in "$@"; do
+ git::commit "$format" "$GIT_COMMIT_MESSAGE"
+ log INFO "$format can be found in $CONTENT_BASE_DIR/$format now"
+ done
+}
diff --git a/packages/git.source.sh b/packages/git.source.sh
new file mode 100644
index 0000000..c502b00
--- /dev/null
+++ b/packages/git.source.sh
@@ -0,0 +1,50 @@
+# Add a static content file to git
+git::add () {
+ if [[ "$USE_GIT" != yes ]]; then
+ return
+ fi
+
+ local -r content_dir="$CONTENT_BASE_DIR/$1"; shift
+ local file="$1"; shift
+ file=${file/$content_dir/.\/}
+
+ cd "$content_dir" &>/dev/null
+ git add "$file"
+ cd - &>/dev/null
+}
+
+# Remove a static content file from git
+git::rm () {
+ if [[ "$USE_GIT" != yes ]]; then
+ return
+ fi
+
+ local -r content_dir="$CONTENT_BASE_DIR/$1"; shift
+ local file="$1"; shift
+ file=${file/$content_dir/.\/}
+
+ cd "$content_dir" &>/dev/null
+ git rm "$file"
+ cd - &>/dev/null
+}
+
+# Commit all changes
+git::commit () {
+ if [[ "$USE_GIT" != yes ]]; then
+ return
+ fi
+
+ local -r content_dir="$CONTENT_BASE_DIR/$1"; shift
+ local -r message="$1"; shift
+
+ cd "$content_dir" &>/dev/null
+ set +e
+ git commit -a -m "$message"
+ if [[ "$GIT_PUSH" == yes ]]; then
+ log INFO "Invoking git pull/push in $content_dir"
+ git pull
+ git push
+ fi
+ set -e
+ cd - &>/dev/null
+}
diff --git a/packages/html.source.sh b/packages/html.source.sh
new file mode 100644
index 0000000..6049c2f
--- /dev/null
+++ b/packages/html.source.sh
@@ -0,0 +1,162 @@
+# Convert special characters to their HTML codes
+html::encode () {
+ $SED '
+ s|\&|\&amp;|g;
+ s|<|\&lt;|g;
+ s|>|\&gt;|g;
+ ' <<< "$@"
+}
+
+# Make a HTML paragraph.
+html::make_paragraph () {
+ local -r text="$1"; shift
+
+ if [[ -n "$text" ]]; then
+ echo "<p>$(html::encode "$text")</p>"
+ fi
+}
+
+# Make a HTML header.
+html::make_heading () {
+ local -r text=$($SED -E 's/^#+ //' <<< "$1"); shift
+ local -r level="$1"; shift
+ echo "<h${level}>$(html::encode "$text")</h${level}>"
+}
+
+# Make a HTML quotation
+html::make_quote () {
+ local -r quote="${1/> }"
+ echo "<p class=\"quote\"><i>$(html::encode "$quote")</i></p>"
+}
+
+# Make a HTML image
+html::make_img () {
+ local link="$1"; shift
+ local descr="$1"; shift
+
+ if [ -z "$descr" ]; then
+ echo -n "<a href=\"$link\"><img src=\"$link\" /></a>"
+ else
+ echo -n "<i>$descr:</i>"
+ echo -n "<a href=\"$link\"><img alt=\"$descr\" title=\"$descr\" src=\"$link\" /></a>"
+ fi
+
+ echo "<br />"
+}
+
+# Make a HTML hyperlink
+html::make_link () {
+ local link="$1"; shift
+ local descr="$1"; shift
+
+ if ! $GREP -F -q '://' <<< "$link"; then
+ link=${link/.gmi/.html}
+ fi
+
+ if [[ -z "$descr" ]]; then
+ descr="$link"
+ fi
+
+ echo "<a class=\"textlink\" href=\"$link\">$descr</a><br />"
+}
+
+# Convert Gemtext to HTML
+html::fromgmi () {
+ local is_list=no
+ local is_plain=no
+
+ while IFS='' read -r line; do
+ if [[ "$is_list" == yes ]]; then
+ if [[ "$line" == '* '* ]]; then
+ echo "<li>$(html::encode "${line/\* /}")</li>"
+ else
+ is_list=no
+ echo "</ul>"
+ fi
+ continue
+
+ elif [[ "$is_plain" == yes ]]; then
+ if [[ "$line" == '```'* ]]; then
+ echo "</pre>"
+ is_plain=no
+ else
+ html::encode "$line"
+ fi
+ continue
+ fi
+
+ case "$line" in
+ '* '*)
+ is_list=yes
+ echo "<ul>"
+ echo "<li>${line/\* /}</li>"
+ ;;
+ '```'*)
+ is_plain=yes
+ echo "<pre>"
+ ;;
+ '# '*)
+ html::make_heading "$line" 1
+ ;;
+ '## '*)
+ html::make_heading "$line" 2
+ ;;
+ '### '*)
+ html::make_heading "$line" 3
+ ;;
+ '> '*)
+ html::make_quote "$line"
+ ;;
+ '=> '*)
+ generate::make_link html "$line"
+ ;;
+ *)
+ html::make_paragraph "$line"
+ ;;
+ esac
+ done
+}
+
+# Test HTML package.
+html::test () {
+ local line='Hello world! This is a paragraph.'
+ assert::equals "$(html::make_paragraph "$line")" '<p>Hello world! This is a paragraph.</p>'
+
+ line=''
+ assert::equals "$(html::make_paragraph "$line")" ''
+
+ line='Foo &<>& Bar!'
+ assert::equals "$(html::make_paragraph "$line")" '<p>Foo &amp;&lt;&gt;&amp; Bar!</p>'
+
+ line='# Header 1'
+ assert::equals "$(html::make_heading "$line" 1)" '<h1>Header 1</h1>'
+
+ line='## Header 2'
+ assert::equals "$(html::make_heading "$line" 2)" '<h2>Header 2</h2>'
+
+ line='### Header 3'
+ assert::equals "$(html::make_heading "$line" 3)" '<h3>Header 3</h3>'
+
+ line='> This is a quote'
+ assert::equals "$(html::make_quote "$line")" '<p class="quote"><i>This is a quote</i></p>'
+
+ line='=> https://example.org'
+ assert::equals "$(generate::make_link html "$line")" \
+ '<a class="textlink" href="https://example.org">https://example.org</a><br />'
+
+ line='=> index.html'
+ assert::equals "$(generate::make_link html "$line")" \
+ '<a class="textlink" href="index.html">index.html</a><br />'
+
+ line='=> http://example.org Description of the link'
+ assert::equals "$(generate::make_link html "$line")" \
+ '<a class="textlink" href="http://example.org">Description of the link</a><br />'
+
+ line='=> http://example.org/image.png'
+ assert::equals "$(generate::make_link html "$line")" \
+ '<a href="http://example.org/image.png"><img src="http://example.org/image.png" /></a><br />'
+
+ line='=> http://example.org/image.png Image description'
+ assert::equals "$(generate::make_link html "$line")" \
+ '<i>Image description:</i><a href="http://example.org/image.png"><img alt="Image description" title="Image description" src="http://example.org/image.png" /></a><br />'
+}
diff --git a/packages/log.source.sh b/packages/log.source.sh
new file mode 100644
index 0000000..56c6587
--- /dev/null
+++ b/packages/log.source.sh
@@ -0,0 +1,30 @@
+# Log a message.
+log () {
+ local -r level="$1"; shift
+ local message
+
+ for message in "$@"; do
+ echo "$message"
+ done | log::_pipe "$level"
+}
+
+# Log a stream through a pipe.
+log::pipe () {
+ log::_pipe "$1"
+}
+
+# Internal log implementation.
+log::_pipe () {
+ local -r level="$1"; shift
+
+ if [[ "$level" == VERBOSE && -z "$LOG_VERBOSE" ]]; then
+ return
+ fi
+
+ local -r callee=${FUNCNAME[2]}
+ local -r stamp=$($DATE +%Y%m%d-%H%M%S)
+
+ while read -r line; do
+ echo "$level|$stamp|$callee|$line" >&2
+ done
+}
diff --git a/packages/md.source.sh b/packages/md.source.sh
new file mode 100644
index 0000000..e7bfae1
--- /dev/null
+++ b/packages/md.source.sh
@@ -0,0 +1,63 @@
+# Make a Markdown image.
+md::make_img () {
+ local link="$1"; shift
+ local descr="$1"; shift
+
+ if [ -z "$descr" ]; then
+ echo "[![$link]($link)]($link) "
+ else
+ echo "[![$descr]($link \"$descr\")]($link) "
+ fi
+}
+
+# Make a Markdown hyperlink.
+md::make_link () {
+ local link="$1"; shift
+ local descr="$1"; shift
+
+ if ! $GREP -F -q '://' <<< "$link"; then
+ link=${link/.gmi/.md}
+ fi
+ if [[ -z "$descr" ]]; then
+ descr="$link"
+ fi
+
+ echo "[$descr]($link) "
+}
+
+# Convert Gemtext to Markdown.
+md::fromgmi () {
+ while IFS='' read -r line; do
+ case "$line" in
+ '=> '*)
+ generate::make_link md "$line"
+ ;;
+ *)
+ echo "$line"
+ ;;
+ esac
+ done
+}
+
+# Test the Markdown package.
+md::test () {
+ local line='=> https://example.org'
+ assert::equals "$(generate::make_link md "$line")" \
+ '[https://example.org](https://example.org) '
+
+ line='=> index.md'
+ assert::equals "$(generate::make_link md "$line")" \
+ '[index.md](index.md) '
+
+ line='=> http://example.org Description of the link'
+ assert::equals "$(generate::make_link md "$line")" \
+ '[Description of the link](http://example.org) '
+
+ line='=> http://example.org/image.png'
+ assert::equals "$(generate::make_link md "$line")" \
+ '[![http://example.org/image.png](http://example.org/image.png)](http://example.org/image.png) '
+
+ line='=> http://example.org/image.png Image description'
+ assert::equals "$(generate::make_link md "$line")" \
+ '[![Image description](http://example.org/image.png "Image description")](http://example.org/image.png) '
+}