ssh git@grit.deadkey.org man

mailmanpat/jenny · 2026-09-06 09:36:46 +08002026-9-6 +8git clone …

FILES / bin / jenny

NameLast CommitTimestampSize
..
jenny 5479d3a h Take the markdown parser as a submodule instead of vendoring it 26-09-05 10:06 14 KB
#!/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