# 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
}
# Markdown internal href format, we use it also for HTML
generate::internal_link_id () {
local -r text="$1"; shift
# Replace uppercase with lowercase
# Replace ' and space with dashes
# Remove all other characters but alnum
tr '[:upper:]' '[:lower:]' <<< "$text" | tr "' " '-' | tr -cd 'A-Za-z0-9-'
}
# Atomically replace a file only if content actually changed, preserving mtime
# for downstream skip logic. Removes the temp file if unchanged.
generate::safe_overwrite () {
local -r tmp_file="$1"; shift
local -r dest_file="$1"; shift
if [[ -f "$dest_file" ]] && diff -q "$tmp_file" "$dest_file" >/dev/null 2>&1; then
rm "$tmp_file"
else
mv "$tmp_file" "$dest_file"
fi
}
# Extract the first heading from a .gmi file as a title, with double quotes
# replaced by single quotes for safe embedding in feeds and HTML attributes.
generate::extract_title () {
local -r file="$1"; shift
$SED -n '/^# / { s/# //; p; q; }' "$file" | tr '"' "'"
}
# Add other docs (e.g. images, videos) from Gemtext to output format.
# Skips copying if the output file already exists and is newer than the source.
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")
# Skip if output already exists and is newer than source
if [[ -f "$dest" ]] && [[ "$dest" -nt "$src" ]]; then
return
fi
if [[ ! -d "$dest_dir" ]]; then
mkdir -p "$dest_dir"
fi
cp "$src" "$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
rm "$src"
fi
}
# Internal helper function for generate::fromgmi
generate::_to_output_format () {
local -r src="$1"; shift
local -r current_page="$1"; shift
local -r format="$1"; shift
local dest=${src/gemtext/$format}
dest=${dest/.gmi/.$format}
local dest_dir=$(dirname "$dest")
local title=$(generate::extract_title "$src")
if [[ -z "$title" ]]; then
title="$SUBTITLE"
fi
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"
# For HTML, we can override the style sheet per directory.
local stylesheet="$(basename "$HTML_CSS_STYLE")"
local stylesheet_override="${stylesheet/.css/-override.css}"
local script=''
if [ -f "${HTML_JS_SCRIPT:-}" ]; then
script="$(basename "$HTML_JS_SCRIPT")"
fi
if [[ "$CONTENT_BASE_DIR/html" != "$(dirname "$dest")" ]]; then
stylesheet="../$stylesheet"
if [ -n "$script" ]; then
script="../$script"
fi
fi
$SED -i "s|%%TITLE%%|$title|g;
s|%%DOMAIN%%|$DOMAIN|g;
s|%%GEMTEXTER%%|$GEMTEXTER|g;
s|%%MARKDOWN_BASE_URI%%|$MARKDOWN_BASE_URI|g;
s|%%CURRENT_PAGE%%|$current_page|g;
s|%%STYLESHEET%%|$stylesheet|g;
s|%%STYLESHEET_OVERRIDE%%|$stylesheet_override|g;
s|%%SCRIPT%%|$script|g;" "$dest.tmp"
elif [[ "$format" == md ]]; then
md::fromgmi < "$src" >> "$dest.tmp"
else
log ERROR "Unknown output format '$format'"
exit 2
fi
mv "$dest.tmp" "$dest"
}
# Check if any global dependency (header, footer, CSS, config) has changed
# since the last generation. Sets _force_rebuild=yes if so.
generate::_check_global_deps () {
local -r sentinel="$CONTENT_BASE_DIR/.gemtexter.lastgen"
if [[ "$FORCE_REBUILD" == yes ]]; then
_force_rebuild=yes
return
fi
if [[ ! -f "$sentinel" ]]; then
_force_rebuild=yes
return
fi
local dep
for dep in "$HTML_HEADER" "$HTML_FOOTER" "$HTML_CSS_STYLE" "${HTML_JS_SCRIPT:-}" ./gemtexter.conf; do
if [[ -f "$dep" ]] && [[ "$dep" -nt "$sentinel" ]]; then
log INFO "Global dependency $dep changed, forcing full rebuild"
_force_rebuild=yes
return
fi
done
_force_rebuild=no
}
# Check if a source .gmi file is fresh (all outputs newer than source).
# Returns 0 (true) if all outputs exist and are newer, meaning we can skip.
generate::_is_fresh () {
local -r src="$1"; shift
if [[ "$_force_rebuild" == yes ]]; then
return 1
fi
local format dest
for format in "$@"; do
dest=${src/gemtext/$format}
dest=${dest/.gmi/.$format}
if [[ ! -f "$dest" ]] || [[ "$src" -nt "$dest" ]]; then
return 1
fi
done
return 0
}
# Generate a given output format from a Gemtext file.
generate::fromgmi () {
local -i num_gmi_files=0
local -i num_skipped_files=0
local -i num_doc_files=0
local current_page
local _force_rebuild=no
# Cap concurrent jobs to the number of CPU cores
local -r max_jobs=$(( $(nproc 2>/dev/null || echo 4) ))
log INFO "Generating $* from Gemtext"
# Check if global deps changed (header, footer, CSS, config)
generate::_check_global_deps
# Convert Gemtext Atom feed to HTML Atom feed
atomfeed::convert_to_html
# Add content
while read -r src; do
if test -n "$CONTENT_FILTER" && ! $GREP -q "$CONTENT_FILTER" <<< "$src"; then
continue
fi
# Skip files where all outputs are newer than the source
if generate::_is_fresh "$src" "$@"; then
log VERBOSE "Skipping unchanged $src"
num_skipped_files=$(( num_skipped_files + 1 ))
continue
fi
current_page=$($SED "s|$CONTENT_BASE_DIR/gemtext||;"'s/.gmi$//;' <<< "$src")
num_gmi_files=$(( num_gmi_files + 1 ))
log INFO "Generating output formats from $src"
for format in "$@"; do
# Throttle: wait for a job slot before spawning
while (( $(jobs -rp | wc -l) >= max_jobs )); do
wait -n
done
generate::_to_output_format "$src" "$current_page" "$format" &
done
done < <(find "$CONTENT_BASE_DIR/gemtext" -type f -name \*.gmi)
wait
log INFO "Converted $num_gmi_files Gemtext files (skipped $num_skipped_files unchanged)"
# 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|\.gmi\.tpl|atom.xml|\.tmp)$')
wait
log INFO "Added $num_doc_files other documents to each of $*"
# Remove obsolete files from ./html/.
# Note: The _config.yml is the config file for GitHub pages (md format).
# Anoter note: The CNAME file is required by GitHub pages as well for custom domains.
for format in "$@"; do
find "$CONTENT_BASE_DIR/$format" -type f |
$GREP -E -v '(\.git.*|_config.yml|CNAME|.domains|robots.txt|static|\.tmp)$'|
while read -r src; do
generate::fromgmi_cleanup_docs "$src" "$format"
done &
done
wait
# Add extra content
for format in "$@"; do
if [[ "$format" == html ]]; then
log INFO "Adding HTML theme files "
html::theme &
fi
done
wait
for format in "$@"; do
log INFO "$format can be found in $CONTENT_BASE_DIR/$format now"
done
# Update sentinel file so next run can detect global dep changes
touch "$CONTENT_BASE_DIR/.gemtexter.lastgen"
log INFO "You may want to commit all changes to version control!"
}
# Only generate draft posts
generate::draft () {
if [[ -n "$CONTENT_FILTER" && "$CONTENT_FILTER" != DRAFT- ]]; then
log ERROR "ERROR, you can't set a content filter manually in draft mode"
exit 2
fi
CONTENT_FILTER=DRAFT-
generate::fromgmi "$@"
log INFO 'For HTML preview, open in your browser:'
find "$CONTENT_BASE_DIR/html" -name DRAFT-\*.html
}
generate::test () {
local text="I can't believe it!"
assert::equals "$(generate::internal_link_id "$text")" 'i-can-t-believe-it'
# Test generate::safe_overwrite: dest does not exist, tmp should be moved
local tmp_dir=$(mktemp -d)
echo 'new content' > "$tmp_dir/file.tmp"
generate::safe_overwrite "$tmp_dir/file.tmp" "$tmp_dir/file"
assert::equals "$(cat "$tmp_dir/file")" 'new content'
assert::equals "$(test -f "$tmp_dir/file.tmp" && echo exists || echo gone)" 'gone'
# Test generate::safe_overwrite: dest exists and is identical, mtime preserved
local before_mtime=$(stat -c '%Y' "$tmp_dir/file")
sleep 1
echo 'new content' > "$tmp_dir/file.tmp"
generate::safe_overwrite "$tmp_dir/file.tmp" "$tmp_dir/file"
local after_mtime=$(stat -c '%Y' "$tmp_dir/file")
assert::equals "$before_mtime" "$after_mtime"
assert::equals "$(test -f "$tmp_dir/file.tmp" && echo exists || echo gone)" 'gone'
# Test generate::safe_overwrite: dest exists but differs, content replaced
echo 'different content' > "$tmp_dir/file.tmp"
generate::safe_overwrite "$tmp_dir/file.tmp" "$tmp_dir/file"
assert::equals "$(cat "$tmp_dir/file")" 'different content'
assert::equals "$(test -f "$tmp_dir/file.tmp" && echo exists || echo gone)" 'gone'
# Test generate::extract_title: extracts first heading and sanitizes quotes
echo '# My "Great" Title' > "$tmp_dir/test.gmi"
echo '## Not this one' >> "$tmp_dir/test.gmi"
assert::equals "$(generate::extract_title "$tmp_dir/test.gmi")" "My 'Great' Title"
# Test generate::extract_title: file with no heading returns empty
echo 'Just a paragraph' > "$tmp_dir/noheading.gmi"
assert::equals "$(generate::extract_title "$tmp_dir/noheading.gmi")" ''
rm -rf "$tmp_dir"
}