sbs/sbs

377 lines
10 KiB
Bash
Executable File

#!/bin/sh
# sbs
# A simple and straightforward static site generator
# Copyright (C) 2021-2023 Jake Bauer
# Licensed under the terms of the ISC License, see LICENSE for details.
set -o errexit # Halt processing if an error is encountered
set -o nounset # Do not allow the use of variables that haven't been set
if [ ! -x "$(command -v lowdown)" ]; then
printf "Error: The program 'lowdown' is needed but was not found.\n"
exit 1
fi
# Create a new page, post, or site with the following skeleton content
new()
{
if [ "$#" -lt 2 ]; then
printf "Please provide a subcommand. See sbs(1) for documentation.\n"
exit 1
fi
if [ "$#" -lt 3 ]; then
printf "Please provide a path. See sbs(1) for documentation.\n"
exit 1
fi
if [ -e "$3" ]; then
printf "%s already exists. Doing this will overwrite it.\n" "$3"
printf "Are you sure you want to overwrite it? (y/N): "
read input
input=$(echo "$input" | tr "[:upper:]" "[:lower:]")
if [ "$input" != "y" ] && [ "$input" != "yes" ]; then
return
fi
fi
if [ "$2" = "page" ]; then
{ printf "Title: \nSummary: \n\n"
printf "# [%%title]\n\n"
} > "$3"
if [ "$verbosity" -gt 0 ]; then
printf "Created: %s\n" "$3"
fi
elif [ "$2" = "post" ]; then
{ printf "Title: \nAuthor: \nDate: \nSummary: \n\n"
printf "# [%%title]\n\n"
printf "**Author:** [%%author] | **Published:** [%%date]\n\n"
} > "$3"
if [ "$verbosity" -gt 0 ]; then
printf "Created: %s\n" "$3"
fi
elif [ "$2" = "site" ]; then
mkdir "$3" "$3/content/" "$3/static/" "$3/templates/"
# Create config.ini
{ printf "siteURL = https://example.com/\n"
printf "siteName = %s\n" "$3"
printf "blogDir = blog/\n"
printf "languageCode = en\n"
printf "buildOptions = -Thtml --html-no-skiphtml --html-no-escapehtml\n"
printf "pushCommand = echo 'No command configured.'\n"
} > "$3/config.ini"
# Create template header.html file
{ printf '<!DOCTYPE html>\n'
printf '<html lang="">\n'
printf '<head>\n'
printf '\t<meta charset="utf-8">\n'
printf '\t<meta name="viewport" content="width=device-width, initial-scale=1.0">\n'
printf '\t<meta name="description" content="">\n'
printf '\t<link rel="stylesheet" href="/style.css">\n'
printf '\t<link rel="alternate" type="application/rss+xml" title="RSS feed" href="/feed.xml">\n'
printf '\t<title></title>\n'
printf '</head>\n'
printf '<body>\n'
printf '\t<header></header>\n'
printf '\t<main>\n'
} > "$3/templates/header.html"
# Create template footer.html file
{ printf '\t</main>\n'
printf '\t<footer></footer>\n'
printf '</body>\n'
printf '</html>\n'
} > "$3/templates/footer.html"
# Create style.css
{ printf "html {\n"
printf "\tmax-width: 38em;\n"
printf "\tpadding: 1em;\n"
printf "\tmargin: auto;\n"
printf "\tline-height: 1.4em;\n"
printf "}\n\n"
printf "img {\n"
printf "\tmax-width: 100%%;\n"
printf "}\n"
} > "$3/static/style.css"
if [ "$verbosity" -gt 0 ]; then
printf "Created: %s\n" "$3"
fi
else
printf "Subcommand '%s' not recognized. See sbs(1) for documentation.\n" "$2"
exit 1
fi
exit 0
}
parse_configuration()
{
options="siteURL siteName blogDir languageCode buildOptions pushCommand numFeedEntries"
for key in $options; do
value=$(grep "$key" config.ini | cut -d'=' -f2 | xargs)
if [ -n "$value" ]; then
eval "$key='$value'"
else
printf "Error: %s is not configured.\n" "$key"
exit 1
fi
done
# Validate configuration
if ! echo "$siteURL" | grep -qE '^https?://.*\..*/$'; then
echo "Error: siteURL should be in canonical form (e.g. https://example.com/).\n"
exit 1
fi
}
# Construct a complete atom feed from all blog posts
genfeed()
{
{ printf '<?xml version="1.0" encoding="utf-8"?>\n'
printf '<feed xmlns="http://www.w3.org/2005/Atom">\n'
printf "\t<title>%s</title>\n" "$siteName"
printf "\t<link href=\"%s\" />\n" "$siteURL"
printf "\t<link rel=\"self\" href=\"%sfeed.xml\" />\n" "${siteURL}"
printf "\t<icon>/favicon.png</icon>\n"
printf "\t<updated>%s</updated>\n" "$(date +"%Y-%m-%dT%H:%M:%SZ")"
printf "\t<id>%s</id>\n" "$siteURL"
printf "\t<generator>sbs</generator>\n\n"
} > static/feed.xml
tmp=$(mktemp)
find content/"$blogDir" -type f -name '*.md' | while read -r file; do
# Disclude draft blog posts
if [ -n "$(lowdown -X draft "$file" 2>/dev/null)" ]; then
continue
fi
# Disclude files that don't have a date (likely blog index page)
if [ -z "$(lowdown -X date "$file" 2>/dev/null)" ]; then
continue
fi
printf "%s %s\n" "$(lowdown -X date "$file")" "$file" >> "$tmp"
done
sort -rn "$tmp" | cut -d' ' -f2 | head -n "$numFeedEntries" | while read -r file; do
fileName=$(basename "$file" .md).html
if [ "$fileName" = "index.html" ]; then
fileName=""
fi
subDir=$(dirname "$file" | sed "s/content\///")
title=$(lowdown -X title "$file")
author=$(lowdown -X author "$file")
date=$(lowdown -X date "$file")
{ printf "\t<entry>\n"
printf "\t\t<title>%s</title>\n" "$title"
printf "\t\t<author><name>%s</name></author>\n" "$author"
printf "\t\t<link href=\"%s%s/%s\" />\n" "$siteURL" "$subDir" "$fileName"
printf "\t\t<id>%s%s/%s</id>\n" "$siteURL" "$subDir" "$fileName"
printf "\t\t<updated>%s</updated>\n" "${date}T00:00:00Z"
printf "\t\t<content type=\"html\"><![CDATA[\n%s\n\t\t]]></content>\n" "$(lowdown $buildOptions "$file")"
printf "\t</entry>\n\n"
} >> static/feed.xml
done
numEntries="$(wc -l "$tmp" | awk '{print $1}')"
printf '</feed>\n' >> static/feed.xml
if [ "$verbosity" -gt 0 ]; then
printf "Created: static/feed.xml with %s entries.\n" "$numEntries"
fi
rm "$tmp"
exit 0
}
# Build the pages given as arguments
build()
{
for file in "$@"; do
unset title
unset description
# Stop the filename from being prepended with the path multiple
# times as build() recurses
if ! echo "$file" | grep -q "$cwd"; then
file="$cwd"/"$file"
fi
if [ ! -f "$file" ]; then
if [ -d "$file" ]; then
build "$file"/*
continue
fi
printf "Error: %s does not exist. " "$file"
printf "Are you sure you're in the right directory?\n"
exit 1
fi
# If file does not have .md extension, simply copy it to static/
extension=$(basename "$file" | awk -F'.' '{print $NF}')
if [ "$extension" != "md" ]; then
fileName=$(basename "$file")
subDir=$(dirname "$file" | sed "s/.*\/content//")
mkdir -p "static/$subDir"
# Avoid redundant copies when input file hasn't changed
if [ "$file" -ot "static/$subDir/$fileName" ]; then
if [ "$verbosity" -gt 1 ]; then
printf "static%s/%s up to date.\n" "$subDir" "$fileName"
fi
continue
fi
if [ "$verbosity" -gt 1 ]; then
printf "Creating: static%s/%s...\n" "$subDir" "$fileName"
fi
cp "$file" "static/$subDir"
continue
fi
fileName=$(basename "$file" .md)
subDir=$(dirname "$file" | sed "s/.*\/content//")
mkdir -p "static/$subDir"
# Avoid redundant builds when input file/templates haven't changed
if [ "$file" -ot "static/$subDir/$fileName.html" ] &&
[ "templates/header.html" -ot "static/$subDir/$fileName.html" ] &&
[ "templates/footer.html" -ot "static/$subDir/$fileName.html" ]; then
if [ "$verbosity" -gt 1 ]; then
printf "static%s/%s.html up to date.\n" "$subDir" "$fileName"
fi
countNotBuilt=$(($countNotBuilt + 1))
continue
fi
if [ "$verbosity" -gt 1 ]; then
printf "Creating: static%s/%s.html...\n" "$subDir" "$fileName"
fi
# Extract metadata from markdown doc (if not converted from gmi)
title=${title:-$(lowdown -X title "$file")}
description=${description:-$(lowdown -X summary "$file")}
# Escapes characters from text that might interfere with sed
title=$(echo $title | sed 's/\\/\\\\/g; s/\//\\\//g; s/\^/\\^/g;
s/\[/\\[/g; s/\*/\\*/g; s/\./\\./g; s/\$/\\$/g')
description=$(echo $description | sed 's/\\/\\\\/g; s/\//\\\//g;
s/\^/\\^/g; s/\[/\\[/g; s/\*/\\*/g; s/\./\\./g; s/\$/\\$/g')
# Build and process the output document
lowdown $buildOptions "$file" \
| cat "templates/header.html" - "templates/footer.html" \
| sed -e "s/<title><\/title>/<title>$title - $siteName<\/title>/" \
-e "s/lang=\"\"/lang=\"$languageCode\"/" \
-e "s/content=\"\"/content=\"$description\"/" \
> "static/$subDir/$fileName.html"
countBuilt=$(($countBuilt + 1))
done
}
# Push the contents of the static/ folder using the configured command
push()
{
if [ "$verbosity" -gt 0 ]; then
echo "$pushCommand"
fi
sh -c "$pushCommand"
}
# Walks up the filesystem to the root of the website so it can be built from
# within any subdirectory. Has the side effect of making path-parsing more
# resilient.
walk_back()
{
# config.ini should be in the root of the website's folder structure
while [ ! -f config.ini ]; do
cd ..
if [ $(pwd) = "/" ]; then
printf "Error: Not inside of an sbs site directory.\n"
exit 1
fi
done
# Parse the config just for the siteName variable to ensure we're in the
# right place (in the root of the website's folder structure)
value=$(grep "siteName" config.ini | cut -d'=' -f2 | xargs)
if [ -n "$value" ]; then
eval "siteName='$value'"
else
printf "Error: siteName is not configured.\n"
exit 1
fi
# Check that we are in the root of the website's folder structure
if [ "$(basename $(pwd))" = "$siteName" ]; then
return 0
else
printf "Error: config.ini found but %s is not the root of the site.\n" "$(pwd)"
exit 1
fi
}
verbosity=0
if [ "$#" -gt 1 ]; then
case "$1" in
"-v" )
verbosity=1
shift
;;
"-v"* )
verbosity=2
shift
;;
esac
fi
if [ "$#" -lt 1 ]; then
echo "Please provide a command. See sbs(1) for documentation."
exit 1
fi
case "$1" in
"build")
shift
# Store the current directory so we know where we started
cwd="$(pwd)"
walk_back
parse_configuration
# Allows simply running "sbs build" without path(s)
countBuilt=0
countNotBuilt=0
start_s=$(date +%s)
if [ $# -eq 0 ]; then
cwd=""
build ./content/*
else
build "$@"
fi
end_s=$(date +%s)
if [ "$verbosity" -gt 0 ]; then
printf "Built %d pages in %d seconds.\n" \
$countBuilt "$(($end_s - $start_s))"
if [ "$countNotBuilt" -gt 0 ]; then
printf "%d pages already up to date.\n" \
"$countNotBuilt"
fi
fi
;;
"genfeed")
walk_back
parse_configuration
genfeed
;;
"new")
new "$@"
;;
"push")
walk_back
parse_configuration
push
;;
"version")
echo "v1.5.0" ;
;;
*)
echo "Unknown command. See sbs(1) for documentation."
;;
esac
exit 0