|
#!/usr/bin/env bash |
|
# |
|
# Errors are fatal and unset variables are errors. Pipelines fail on the |
|
# first failing stage rather than the last. Turning these on surfaced a |
|
# handful of latent failures, which is the point. |
|
set -euo pipefail |
|
|
|
# Declare CONSTANTS |
|
PUBLISH_PATTERN="^[0-9]{4}-[0-9]{2}-[0-9]{2}(.*)" |
|
BLOG_LOCK="$(pwd)/.bloglock" |
|
T="[jenny]" |
|
JENNY_VERSION="unknown" |
|
|
|
# Temp files are named so they are identifiable, and removed on any exit. |
|
SOURCE_TEMP_FILE=$(mktemp -t jenny.XXXXXX) |
|
RENDER_TEMP_FILE=$(mktemp -t jenny.XXXXXX) |
|
TAGS_TEMP_DIR=$(mktemp -d -t jenny.XXXXXX) |
|
|
|
# Anything mktemp'd during the build registers itself here, so a failure |
|
# part-way through still cleans up after itself. |
|
JENNY_TEMPFILES=("$SOURCE_TEMP_FILE" "$RENDER_TEMP_FILE" "$TAGS_TEMP_DIR") |
|
|
|
jenny_register_temp() { |
|
JENNY_TEMPFILES+=("$1") |
|
} |
|
|
|
cleanup() { |
|
rm -rf "${JENNY_TEMPFILES[@]}" \ |
|
"$SOURCE_TEMP_FILE.stripped" "$RENDER_TEMP_FILE.stripped" |
|
} |
|
trap cleanup EXIT INT TERM |
|
|
|
# ---------------------------------------------------------------- SHARE_DIR |
|
# |
|
# Resolves the installed share directory, following a symlink chain when |
|
# jenny is invoked through one. JENNY_SHARE_DIR overrides it, which is what |
|
# the test suite uses to run against a working tree. |
|
|
|
resolve_path() { |
|
# Portable readlink -f: macOS's readlink has no -f. |
|
local target=$1 dir |
|
while [ -L "$target" ]; do |
|
dir=$(cd -P "$(dirname "$target")" 2>/dev/null && pwd) |
|
target=$(readlink "$target") |
|
# A relative link resolves against the directory holding the link. |
|
case "$target" in |
|
/*) ;; |
|
*) target="$dir/$target" ;; |
|
esac |
|
done |
|
dir=$(cd -P "$(dirname "$target")" 2>/dev/null && pwd) |
|
printf '%s/%s' "$dir" "$(basename "$target")" |
|
} |
|
|
|
if [ -n "${JENNY_SHARE_DIR:-}" ]; then |
|
SHARE_DIR=$JENNY_SHARE_DIR |
|
else |
|
# $0 may be a bare name found on PATH. |
|
SELF=$0 |
|
if [ "${SELF#/}" = "$SELF" ] && [ "${SELF#*/}" = "$SELF" ]; then |
|
SELF=$(command -v "$SELF" 2>/dev/null || printf '%s' "$SELF") |
|
fi |
|
SELF=$(resolve_path "$SELF") |
|
SHARE_DIR="$(dirname "$(dirname "$SELF")")/share/jenny" |
|
fi |
|
|
|
if [ ! -d "$SHARE_DIR" ]; then |
|
echo "$T Could not locate jenny's share directory (looked in $SHARE_DIR)." >&2 |
|
echo "$T Set JENNY_SHARE_DIR to override." >&2 |
|
exit 1 |
|
fi |
|
|
|
[ -f "$SHARE_DIR/../../VERSION" ] && JENNY_VERSION=$(cat "$SHARE_DIR/../../VERSION") |
|
[ -f "$SHARE_DIR/VERSION" ] && JENNY_VERSION=$(cat "$SHARE_DIR/VERSION") |
|
|
|
# Make the lib directory available to layouts that need to source helpers |
|
JENNY_LIB_DIR="$SHARE_DIR/lib" |
|
export JENNY_LIB_DIR |
|
|
|
# Load library |
|
source "$SHARE_DIR/lib/helpers.sh" |
|
source "$SHARE_DIR/lib/components.sh" |
|
source "$SHARE_DIR/lib/subcommands.sh" |
|
|
|
BASENAME=$(basename "$0") |
|
|
|
# Checks whether GNU utils are needed but missing, and handles suppression |
|
# of the warning. |
|
if needs_gnu_utils; then |
|
echo -e "$T macOS users need ggrep, gsed, gawk, and gdate, install them via: \n\n brew install gawk gnu-sed grep coreutils\n" >&2 |
|
echo -e "$T If you installed them with --with-default-names, i.e. as sed and awk, then add the below to your .blogrc file to suppress this warning:\n\n SUPPRESS_UTILS_WARN=1\n" >&2 |
|
exit 1 |
|
fi |
|
|
|
# Use GNU utils if present |
|
SED=$(command -v gsed || command -v sed) |
|
AWK=$(command -v gawk || command -v awk) |
|
DATE=$(command -v gdate || command -v date) |
|
SUM=$(command -v md5sum || command -v md5) |
|
GREP=$(command -v ggrep || command -v grep) |
|
export SED AWK DATE SUM GREP |
|
|
|
function usage() { |
|
# Reads the option table out of the getopts block below, so the help text |
|
# cannot drift from the options actually accepted. |
|
echo "$BASENAME usage:" |
|
{ |
|
grep -E '^ +[a-z]( \| \*)?\) #' "$0" \ |
|
| sed -E 's/^ +([a-z])( \| \*)?\) # ?/-\1|/' |
|
echo "--version|Show the version and exit" |
|
} | awk -F'|' '{ printf " %-11s %s\n", $1, $2 }' |
|
exit 0 |
|
} |
|
|
|
# Help and version must work outside a blog directory, so they are handled |
|
# before .blogrc is required. |
|
for arg in "$@"; do |
|
case "$arg" in |
|
-h|--help) usage ;; |
|
--version) echo "$BASENAME $JENNY_VERSION"; exit 0 ;; |
|
esac |
|
done |
|
|
|
# Subcommands run after the utils are resolved so they can use the |
|
# indirection, and receive their own arguments cleanly. .blogrc is sourced |
|
# first when present, but is not required -- init runs in an empty directory. |
|
# Hyphens in a subcommand name map to underscores in the function name, so |
|
# `jenny migrate-layout` dispatches to sub_migrate_layout. |
|
SUBCOMMAND=${1:-} |
|
SUBCOMMAND_FN=${SUBCOMMAND//-/_} |
|
if [ -n "${SUBCOMMAND:-}" ] && type "sub_${SUBCOMMAND_FN}" >/dev/null 2>&1; then |
|
source_blogrc >/dev/null 2>&1 || true |
|
"sub_${SUBCOMMAND_FN}" "${@:2}" |
|
exit $? |
|
fi |
|
|
|
# Load configuration on current dir |
|
if source_blogrc; then |
|
echo "$T Sourced .blogrc" |
|
|
|
# Set defaults if not set in .blogrc |
|
[ -z "${POSTS_PER_PAGE:-}" ] && POSTS_PER_PAGE=10 |
|
[ -z "${DIST:-}" ] && DIST=$PWD/.dist |
|
[ -z "${LAYOUT_DIR:-}" ] && LAYOUT_DIR=$SHARE_DIR/layout |
|
[ -z "${MARKDOWN_COMMAND:-}" ] && MARKDOWN_COMMAND="\$AWK -f \$SHARE_DIR/lib/markdown.awk/markdown.awk" |
|
[ -z "${FEED_MAX_ITEMS:-}" ] && FEED_MAX_ITEMS=20 |
|
[ -z "${URL_STYLE:-}" ] && URL_STYLE=html |
|
|
|
# Both are optional in .blogrc -- a blog with no host or title is valid -- |
|
# so they are defaulted here rather than guarded at every use site. |
|
[ -z "${BLOG_HOST:-}" ] && BLOG_HOST="" |
|
[ -z "${BLOG_TITLE:-}" ] && BLOG_TITLE="" |
|
|
|
DO_CLEAN=0 |
|
DO_RESET_LOCK=0 |
|
FORCE_RENDER=0 |
|
|
|
while getopts ":d:p:l:m:vncfh" opt; do |
|
case $opt in |
|
d) # ./dir Override the build directory setting |
|
DIST="$OPTARG" |
|
;; |
|
l) # ./dir Override layout directory setting |
|
LAYOUT_DIR="$OPTARG" |
|
;; |
|
p) # 10 Override posts per page setting |
|
POSTS_PER_PAGE="$OPTARG" |
|
;; |
|
m) # mmd Override the markdown parser command setting |
|
MARKDOWN_COMMAND="$OPTARG" |
|
;; |
|
v) # Display more information during building |
|
VERBOSE="yes" |
|
;; |
|
n) # Ignore the .bloglock file when building |
|
DO_RESET_LOCK=1 |
|
;; |
|
c) # Empty out the build folder prior to building |
|
DO_CLEAN=1 |
|
;; |
|
f) # Force a full rebuild without emptying the lock |
|
FORCE_RENDER=1 |
|
;; |
|
h | *) # Show this help message |
|
usage |
|
;; |
|
\?) echo "Invalid option -$OPTARG" >&2 |
|
exit 1 |
|
;; |
|
esac |
|
done |
|
|
|
# Destructive actions run after every option is parsed, so that the -d |
|
# given alongside -c is the directory that actually gets cleaned. |
|
if [ "$DO_RESET_LOCK" -eq 1 ]; then |
|
echo "$T Emptying $BLOG_LOCK" |
|
: > "$BLOG_LOCK" |
|
fi |
|
|
|
if [ "$DO_CLEAN" -eq 1 ]; then |
|
if ! is_safe_dist "$DIST"; then |
|
echo "$T Refusing to clean '$DIST' -- it is empty, a root, a home directory, or outside the current tree." >&2 |
|
exit 1 |
|
fi |
|
echo "$T Cleaning $DIST" |
|
rm -rf "$DIST" |
|
fi |
|
|
|
case "$URL_STYLE" in |
|
html|extensionless) ;; |
|
*) |
|
echo "$T Unknown URL_STYLE '$URL_STYLE' in .blogrc" >&2 |
|
echo "$T Expected 'html' (default) or 'extensionless'." >&2 |
|
exit 1 |
|
;; |
|
esac |
|
|
|
# A layout that still consumes the old eval-based payload would render |
|
# garbage silently, so refuse to build against one. |
|
assert_layouts_current "$LAYOUT_DIR" || exit 1 |
|
|
|
# Everything that shapes the output but is not the document: the layouts and |
|
# the settings they read. Recorded with each lock entry so a theme edit |
|
# invalidates rendered files the way a content edit does. |
|
BUILD_SUM=$(build_signature "$LAYOUT_DIR") |
|
export BUILD_SUM |
|
|
|
# Sets up directory |
|
touch "$BLOG_LOCK" |
|
mkdir -p "$DIST/post" |
|
mkdir -p "$DIST/page" |
|
|
|
if [ -n "${STATIC_DIRS:-}" ]; then |
|
# STATIC_DIRS is a space-separated list in .blogrc; split it explicitly |
|
# rather than handing the string to eval. |
|
read -r -a static_dirs_arr <<< "$STATIC_DIRS" |
|
for d in "${static_dirs_arr[@]}"; do |
|
[ -e "$d" ] && cp -R "$d" "$DIST" |
|
done |
|
fi |
|
else |
|
echo "$T Could not find .blogrc in this directory, exiting." >&2 |
|
exit 1 |
|
fi |
|
|
|
[ -n "${VERBOSE:-}" ] && echo |
|
[ -n "${VERBOSE:-}" ] && echo "$T Build directory: $DIST" |
|
[ -n "${VERBOSE:-}" ] && echo "$T Layout directory: $LAYOUT_DIR" |
|
[ -n "${VERBOSE:-}" ] && echo "$T Posts per page: $POSTS_PER_PAGE" |
|
[ -n "${VERBOSE:-}" ] && echo "$T Markdown command: $MARKDOWN_COMMAND" |
|
[ -n "${VERBOSE:-}" ] && echo |
|
|
|
[ -n "${VERBOSE:-}" ] && echo "$T grep: ${GREP}" |
|
[ -n "${VERBOSE:-}" ] && echo "$T sed: ${SED}" |
|
[ -n "${VERBOSE:-}" ] && echo "$T awk: ${AWK}" |
|
[ -n "${VERBOSE:-}" ] && echo "$T date: ${DATE}" |
|
[ -n "${VERBOSE:-}" ] && echo "$T sum: ${SUM}" |
|
[ -n "${VERBOSE:-}" ] && echo |
|
|
|
# Declare GlobalVariables |
|
declare -a IndexList=() |
|
declare -a PageList=() |
|
declare -a FeedList=() |
|
BUILD_FAILED=0 |
|
|
|
begin () { |
|
|
|
if [ -z "${_TAGNAME:-}" ]; then |
|
echo "$T Generating main index" |
|
else |
|
echo "$T Generating ${_TAGNAME:-} tag index" |
|
fi |
|
|
|
# Grabbing the array argument |
|
local list=("$@") |
|
|
|
local total_post_count total_page_count |
|
total_post_count=$(get_total_post_count "$@") |
|
total_page_count=$(get_total_page_count "$total_post_count" "$POSTS_PER_PAGE") |
|
|
|
# Reset variables |
|
local post_index=0 |
|
|
|
IndexList=() |
|
PageList=() |
|
FeedList=() |
|
|
|
# Loop through file list in expanded array |
|
local f |
|
for f in "${list[@]}"; do |
|
local full_file_name filename_sum title |
|
full_file_name=$(get_full_filename "$f") |
|
filename_sum=$(echo "$full_file_name" | $SUM | cut -d ' ' -f 1) |
|
title=$(get_title "$f") |
|
|
|
# Process source files if not working on tag index |
|
if [ -z "${_TAGNAME:-}" ]; then |
|
|
|
if is_draft "$f"; then |
|
echo "$T • Draft: $full_file_name file.." |
|
continue |
|
fi |
|
|
|
if is_scheduled "$f"; then |
|
echo "$T • Scheduled: $full_file_name file.." |
|
continue |
|
fi |
|
|
|
local content_sum |
|
content_sum=$(cat "$f" | $SUM | cut -d ' ' -f 1) |
|
|
|
echo "$T ☶ $full_file_name" |
|
local forRendering=false |
|
local slug |
|
if is_new "$filename_sum"; then |
|
slug=$(get_unique_slug "$f") |
|
forRendering=true |
|
echo "$T + /post/$slug.html" |
|
echo "$slug $filename_sum $content_sum $BUILD_SUM" >> "$BLOG_LOCK" |
|
else |
|
# not new |
|
slug=$(get_existing_slug "$filename_sum") |
|
if is_changed "$filename_sum" "$content_sum" "$BUILD_SUM"; then |
|
echo "$T ↑ /post/$slug.html" |
|
$SED -i "/ $filename_sum /d" "$BLOG_LOCK" |
|
echo "$slug $filename_sum $content_sum $BUILD_SUM" >> "$BLOG_LOCK" |
|
forRendering=true |
|
fi |
|
fi |
|
|
|
# A post whose output went missing is rendered again even when the |
|
# lock says it is unchanged. |
|
if [ ! -f "$_DIST/post/$slug.html" ]; then |
|
forRendering=true |
|
fi |
|
|
|
[ "$FORCE_RENDER" -eq 1 ] && forRendering=true |
|
|
|
# Titles fall back to the slug so a post without a heading still builds. |
|
if [ -z "$title" ]; then |
|
echo "$T ! No '# ' heading in $full_file_name, using the slug as the title" >&2 |
|
title=$slug |
|
fi |
|
|
|
|
|
# Grab the tags |
|
local tags tags_valid="" |
|
tags=$(get_tags "$f") |
|
local i |
|
for i in $tags; do |
|
if is_valid_tag "$i"; then |
|
echo "$f" >> "$TAGS_TEMP_DIR/$i" |
|
tags_valid="$tags_valid $i" |
|
else |
|
echo "$T ! Skipping invalid tag '$i' in $full_file_name (allowed: A-Z a-z 0-9 _ -)" >&2 |
|
fi |
|
done |
|
tags=${tags_valid# } |
|
|
|
# If page is for rendering |
|
if [ "$forRendering" = true ]; then |
|
local destination="$_DIST/post/$slug.html" |
|
if ! render "$f" "$destination" "$title" "$slug" "$tags"; then |
|
BUILD_FAILED=1 |
|
continue |
|
fi |
|
fi |
|
|
|
else |
|
slug=$(get_existing_slug "$filename_sum") |
|
[ -z "$title" ] && title=$slug |
|
echo "$T ∙ /post/$slug.html" |
|
fi # Checks if working on tag index |
|
|
|
post_index=$((post_index + 1)) |
|
|
|
local page=$(( (post_index + POSTS_PER_PAGE - 1) / POSTS_PER_PAGE )) |
|
|
|
# Finally insert to the current index page |
|
_DIST=$_DIST _TAGNAME=${_TAGNAME:-} \ |
|
index_insert "$f" "$slug" "$title" \ |
|
"$total_post_count" "$total_page_count" "$post_index" "$page" |
|
|
|
done |
|
|
|
# The feed is generated once, from the complete list, rather than from |
|
# whatever the last pagination branch happened to hold. |
|
echo "$T ⌁ Generating RSS feed" |
|
generate_feed "$_DIST" "${_TAGNAME:-}" |
|
|
|
echo |
|
} |
|
|
|
# Clean DIST folder first |
|
_DIST=$DIST clean |
|
|
|
# Get files in current directory |
|
shopt -s nullglob |
|
sortedList=(*.md) |
|
shopt -u nullglob |
|
|
|
PAGES_DIR=${PAGES_DIR:-pages} |
|
|
|
if [ ${#sortedList[@]} -eq 0 ] && [ ! -d "$PAGES_DIR" ]; then |
|
echo "$T There are no posts (*.md) in $(pwd) and no $PAGES_DIR/ directory, nothing to build." >&2 |
|
exit 1 |
|
fi |
|
|
|
# Ordering depends on the glob sorting by date, which holds for the |
|
# YYYY-MM-DD prefix jenny mandates. Sorted explicitly so the assumption is |
|
# stated rather than inherited from the shell. |
|
fileList=() |
|
while IFS= read -r line; do |
|
[ -n "$line" ] && fileList+=("$line") |
|
done < <(printf '%s\n' "${sortedList[@]}" | LC_ALL=C sort) |
|
|
|
# Start generation |
|
if [ ${#fileList[@]} -gt 0 ]; then |
|
_DIST=$DIST begin "${fileList[@]}" |
|
fi |
|
|
|
# Pages: everything under pages/ renders at its own path. Outside the |
|
# chronological pass entirely -- no index, no feed, no tags. |
|
# Last build's generated listings go before this one's are written, so a |
|
# directory that has since gained an index.md of its own keeps the author's |
|
# rather than a stale generated one. |
|
sweep_generated_indexes "$PAGES_DIR" |
|
generate_section_indexes "$PAGES_DIR" |
|
|
|
sweep_pages "$PAGES_DIR" "$DIST" |
|
render_pages "$PAGES_DIR" "$DIST" |
|
|
|
# Drop output and lock entries for sources that no longer exist. |
|
sweep_orphans "$DIST" |
|
|
|
# Now generate indexes for tags |
|
if compgen -G "$TAGS_TEMP_DIR/*" > /dev/null; then |
|
for i in "$TAGS_TEMP_DIR"/*; do |
|
tag_name=$(basename "$i") |
|
is_valid_tag "$tag_name" || continue |
|
mkdir -p "$DIST/tag/$tag_name" |
|
tagFileList=() |
|
while IFS= read -r line; do |
|
[ -n "$line" ] && tagFileList+=("$line") |
|
done < "$i" |
|
_DIST=$DIST/tag/$tag_name _TAGNAME=$tag_name begin "${tagFileList[@]}" |
|
done |
|
generate_tag_index "$DIST" "$TAGS_TEMP_DIR" |
|
fi |
|
|
|
# A 404 page, so hosts that resolve extensionless URLs do not fall back to |
|
# serving the index for every unmatched path. Skipped without complaint when |
|
# a custom LAYOUT_DIR does not provide one -- it is an addition, and an older |
|
# layout set should keep working. |
|
if [ -f "$LAYOUT_DIR/404.sh" ]; then |
|
BLOG_HOST="$BLOG_HOST" \ |
|
BLOG_TITLE="$BLOG_TITLE" \ |
|
NAVS_DIR="${NAVS_DIR:-navs}" \ |
|
DIAGRAM_SCRIPT="${DIAGRAM_SCRIPT:-}" \ |
|
SED="$SED" AWK="$AWK" DATE="$DATE" GREP="$GREP" \ |
|
"$LAYOUT_DIR/404.sh" > "$DIST/404.html" |
|
fi |
|
|
|
# Run the user defined hook |
|
[ "$(type -t post_hook)" = function ] && post_hook |
|
|
|
if [ "$BUILD_FAILED" -ne 0 ]; then |
|
echo "$T Build finished with errors." >&2 |
|
exit 1 |
|
fi |
|
|
|
echo "$T Your rendered files are at $DIST" |
|
|
|
exit 0 |