Commit Diff


commit - /dev/null
commit + b77b250a6fbc0ecf95bf91dff9ec25f6a5c4260b
blob - /dev/null
blob + 042c10d5233cd0e9cb438930d675a1e979417de0 (mode 644)
--- /dev/null
+++ readme.md
@@ -0,0 +1 @@
+https://romanzolotarev.com/ssg/
blob - /dev/null
blob + a785d7f2fd619836522e66ce6d8d616c7fc05ed4 (mode 755)
--- /dev/null
+++ ssg.sh
@@ -0,0 +1,399 @@
+#!/bin/ksh -eu
+
+# https://romanzolotarev.com/ssg/
+# copyright 2018-2026 romanzolotarev.com
+#
+# permission to use, copy, modify, and/or distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+#
+# the software is provided "as is" and the author disclaims all warranties with
+# regard to this software including all implied warranties of merchantability
+# and fitness. in no event shall the author be liable for any special, direct,
+# indirect, or consequential damages or any damages whatsoever resulting from
+# loss of use, data or profits, whether in an action of contract, negligence or
+# other tortious action, arising out of or in connection with the use or
+# performance of this software.
+
+info() { echo "$@" >&2; }
+fail() { echo "$@" >&2 && exit 1; }
+usage() { fail 'usage: '"${0##*/}"' <src> <dst>'; }
+
+# exit if less than two arguments
+fail_no_args() { if test $# -ne 2; then usage; fi; }
+
+# exit if src directory not found
+fail_no_src() { if ! test -d "$1"; then fail "fail: $1 not found"; fi; }
+
+# return relative paths sorted
+sort_relative() { sed "s,$1/,," | sort; }
+
+# return sorted file hashes for directory
+hash_dir() {
+	dir="$1"
+	if ! test -d "$dir"; then return; fi
+	set -- find "$dir" -type f
+	while read -r line; do
+		if test -z "$line"; then continue; fi
+		set -f && for word in $line; do
+			if test -z "$word"; then continue; fi && set -- "$@" "$word"
+		done && set +f
+	done
+	"$@" -print0 | xargs -r -0 -n256 -P "$NCPU" sha256 -r | sort_relative "$dir"
+}
+
+# return find expression to exclude paths
+not_path() {
+	while read -r x; do
+		case "$x" in
+		*/) echo "! -path $x*" ;;
+		*) echo "! -path $x" ;;
+		esac
+	done
+}
+
+# return all .ssg.ignore files
+find_ignore_files() { find "$SRC" -type f -name "$SSG_IGNORE"; }
+
+# return ignored paths from all .ssg.ignore files
+ignored_paths() {
+	while read -r f; do d="$(dirname "$f")" && sed "s,^,${d}/," "$f"; done
+}
+
+# return src directory hash, excluding paths from .ssg.ignore
+hash_src() { find_ignore_files | ignored_paths | not_path | hash_dir "$SRC"; }
+
+# return find expression to exclude src and dst hash files
+exclude_hash_files() { echo "! -name $SSG_DST ! -name $SSG_SRC"; }
+
+# return dst directory hash, excluding hash files
+hash_dst() { exclude_hash_files | hash_dir "$DST"; }
+
+mustache() {
+	d=0
+	s=-1
+	while read -r line; do
+		o=""
+		rest="$line"
+		while :; do
+			case "$rest" in
+			*'{{'*)
+				before="${rest%%\{\{*}"
+				rest="${rest#*\{\{}"
+				tag="${rest%%\}\}*}"
+				rest="${rest#*\}\}}"
+				if test $s -lt 0; then o="${o}${before}"; fi
+				case "$tag" in
+				'#'*)
+					d=$((d + 1))
+					if test $s -lt 0 -a -z "$(printenv "${tag#\#}")"; then s=$d; fi
+					;;
+				'/'*) if test $s -eq $d; then s=-1; fi && d=$((d - 1)) ;;
+				'^'*)
+					d=$((d + 1))
+					if test $s -lt 0 -a -n "$(printenv "${tag#^}")"; then s=$d; fi
+					;;
+				*) if test $s -lt 0; then o="${o}$(printenv "$tag")"; fi ;;
+				esac
+				;;
+			*) if test $s -lt 0; then o="${o}${rest}"; fi && break ;;
+			esac
+		done
+		if test $s -lt 0 -o -n "$o"; then printf '%s\n' "$o"; fi
+	done
+}
+
+# returns page rendered with its template
+render_page() {
+	# strip newlines and escape ampersands and slashes
+	esc() { tr -d '\n' | sed 's/[&/]/\\&/g'; }
+	# replace newlines with spaces and extract title from the first <h1> tag
+	get_title() { tr '\n' ' ' |
+		sed -n 's/^[^<]*<[Hh]1[^>]*>\([^<]*\)<\/[Hh]1[^>]*>.*/\1/p'; }
+	content="$(cat)"
+	# use src dir name as site name
+	site="$(basename "$SRC" | esc)"
+	title="$(printf '%s' "$content" | get_title | esc)"
+	# replace {{title}} and {{site}} tags with values from variables,
+	# replace {{content}} tag with page content.
+	# use truthy tag to show title only when it's found in content, for example:
+	# {{#title}}{{title}: {{/title}}
+	export content site title && mustache <"$1"
+}
+
+# return html converted from markdown
+md_to_html() {
+	lowdown \
+		--out-no-smarty \
+		--html-no-escapehtml \
+		--html-no-skiphtml \
+		--parse-no-autolink \
+		--parse-no-metadata
+}
+
+# write zip unless its gz variant found in src
+gz() {
+	if test -f "$SRC/$1.gz"; then return; fi
+	gzip -n -9 <"$DST/$1" >"$DST/$1.gz"
+}
+
+# return nearest template for page
+find_template() {
+	dir=$(cd "$(dirname "$SRC/$1")" && pwd)
+	root=$(dirname "$(cd "$SRC" && pwd)")
+	while test "$dir" != "$root"; do
+		t="$dir/$SSG_TEMPLATE"
+		if test -f "$t"; then echo "${t#"$SRC/"}" && return; fi
+		dir=$(dirname "$dir")
+	done
+}
+
+# return relative paths to files if they found in directory
+files_in() {
+	while read -r f; do if test -f "$1/$f"; then echo "$f"; fi; done
+}
+
+# write and zip file to dst
+generate_file() {
+	mkdir -p "$(dirname "$DST/$1")"
+	cp "$SRC/$1" "$DST/$1"
+	info "file      $1"
+	gz "$1"
+	info "file      $1 > $1.gz"
+}
+
+# write file to dst
+generate_copy() {
+	mkdir -p "$(dirname "$DST/$1")"
+	cp "$SRC/$1" "$DST/$1"
+	info "copy      $1"
+}
+
+# execute script and write zips for output files to dst
+generate_sh() {
+	command ksh -- "$SRC/$1" "$SRC" "$DST" |
+		while read -r f; do
+			if test -z "$f"; then continue; fi
+			if test -f "$SRC/$f"; then fail "fail: $1 collides with $f"; fi
+			info "sh        $1 > $f"
+			gz "$f"
+			info "sh        $1 > $f.gz"
+		done
+}
+
+# return true page has <html> tag
+has_html_tag() { grep -qi '<html[^>]*>' "$SRC/$1"; }
+
+# write html page and zip to dst
+generate_html() {
+	mkdir -p "$(dirname "$DST/$1")"
+
+	# return content as is for pages with <html> tag
+	if has_html_tag "$1"; then
+		cp "$SRC/$1" "$DST/$1"
+		info "html      $1"
+		gz "$1"
+		info "html      $1 > $1.gz"
+		return
+	fi
+
+	# find template
+	t=$(find_template "$1")
+
+	if test -f "$SRC/$t"; then
+		# return page rendered with template
+		render_page "$SRC/$t" <"$SRC/$1" >"$DST/$1"
+		info "html      $1, $t > $1"
+		gz "$1"
+		info "html      $1, $t > $1.gz"
+	else
+		# ...or return content as is if template not found
+		cp "$SRC/$1" "$DST/$1"
+		info "html      $1"
+		gz "$1"
+		info "html      $1 > $1.gz"
+	fi
+}
+
+# write markdown page and zip to dst
+generate_md() {
+	mkdir -p "$(dirname "$DST/$1")"
+	t=$(find_template "$1")
+	h="${1%.md}.html"
+	if test -f "$SRC/$h"; then fail "fail: $1 collides with $h"; fi
+	if test -f "$SRC/$t"; then
+		md_to_html <"$SRC/$1" | render_page "$SRC/$t" >"$DST/$h"
+		info "md        $1, $t > $h"
+		gz "$h"
+		info "md        $1, $t > $h.gz"
+	else
+		md_to_html <"$SRC/$1" >"$DST/$h"
+		info "md        $1 > $h"
+		gz "$h"
+		info "md        $1 > $h.gz"
+	fi
+}
+
+# return diff of two values
+diff_lines() {
+	fifo=$(mktemp -u) || fail 'fail: diff lines: can not mktemp'
+	mkfifo "$fifo" || fail 'fail: diff lines: can not mkfifo'
+	(printf '%s\n' "$2" >"$fifo") &
+	printf '%s\n' "$1" | diff - "$fifo" || :
+	rm -f "$fifo"
+}
+
+# return second column and sort
+cut_sort() { cut -d' ' -f2 | sort; }
+
+# return pages with their templates, excluding pages with <html> tag
+pages_with_templates() {
+	while read -r p; do
+		if has_html_tag "$p"; then continue; fi &&
+			printf '%s\t%s\n' "$(find_template "$p")" "$p"
+	done
+}
+
+# return pages related to template
+pages_by_templates() {
+	grep "$SSG_TEMPLATE" | cut_sort |
+		while read -r t; do echo "$1" | grep "$t" | cut -f2; done
+}
+
+# return file expected in dst directory
+plan() {
+	while read -r k f; do
+		case "$k" in
+		copy) echo "$f" ;;
+		file) echo "$f" && echo "$f.gz" ;;
+		html) echo "$f" && echo "$f.gz" ;;
+		md) echo "${f%.md}.html" && echo "${f%.md}.html.gz" ;;
+		sh) command ksh -- "$SRC/$f" |
+			while read -r f; do
+				if test -z "$f"; then continue; fi && echo "$f" && echo "$f.gz"
+			done ;;
+		*) continue ;;
+		esac
+	done
+}
+
+# make dst directory and return src hash as is
+mkdir_select_all() { mkdir -p "$DST" && echo "$1" | cut_sort; }
+
+# remove dst directory and return src hash as is
+rmdir_select_all() { rm -rf "$DST" && echo "$1" | cut_sort; }
+
+# remove files in dst
+rm_files() { while read -r f; do rm "$DST/$f" && info "rm        $f"; done; }
+
+# remove empty directories in dst
+rm_empty_dirs() {
+	find "$DST" -type d -mindepth 1 | while read -r d; do
+		if test -z "$(find "$d" -mindepth 1 -type f)"; then echo "$d"; fi
+	done | sort -r | while read -r e; do
+		rmdir "$e" && info "rmdir     ${e#"$DST"/}/"
+	done
+}
+
+# return files with their kind prepended
+prepend_kind() {
+	while read -r f; do
+		if test -z "$f"; then continue; fi
+		case "$f" in
+		*.html) k='html' ;;
+		*.md) k='md' ;;
+		*.ssg.ignore) k='ignore' ;;
+		*.ssg.*.sh | *.ssg.sh) k='sh' ;;
+		*.ssg.template) k='template' ;;
+		*.png | *.jpg | *.gif | *.mp4 | *.zip | *.gz) k='copy' ;;
+		*) k='file' ;;
+		esac
+		echo "$k $f"
+	done
+}
+
+# return right side of diff
+select_right() { sed -n 's/^> \([^\	]*\).*/\1/p'; }
+
+# remove files and directories not present in plan from dst
+clean_up_dst() {
+	dst_plan=$(echo "$1" | cut_sort | prepend_kind | plan | sort)
+	dst_files=$(echo "$2" | sort_relative "$DST" | cut_sort)
+	diff_lines "$dst_plan" "$dst_files" | select_right | files_in "$DST" |
+		rm_files
+	rm_empty_dirs
+}
+
+# return sorted list of unique updated files and pages for updated template
+select_updated() {
+	src_updated=$(echo "$2" | select_right | cut_sort)
+	pages=$(echo "$1" | grep -E '.html|.md' | cut_sort | pages_with_templates)
+	{
+		echo "$src_updated"
+		echo "$src_updated" | pages_by_templates "$pages"
+	} | sort -u
+}
+
+is_empty() { test -z "$1"; }
+is_dir() { test -d "$1"; }
+is_ssg_dst() { test -f "$DST/$SSG_DST"; }
+is_ssg_src() { test -f "$DST/$SSG_SRC"; }
+is_matching_ssg_dst() {
+	test "$(sha256 <"$DST/$SSG_DST")" = "$(echo "$1" | sha256)"
+}
+diff_src() { diff_lines "$(cat "$DST/$SSG_SRC")" "$1"; }
+
+# return files to be updated
+select_src_files() {
+	if is_empty "$1"; then return; fi
+	if ! is_dir "$DST"; then mkdir_select_all "$1" && return; fi
+	if ! is_ssg_src || ! is_ssg_dst; then rmdir_select_all "$1" && return; fi
+	dst_hash=$(hash_dst)
+	if ! is_matching_ssg_dst "$dst_hash"; then rmdir_select_all "$1" && return; fi
+	src_hash_diff=$(diff_src "$1")
+	if is_empty "$src_hash_diff"; then return; fi
+	clean_up_dst "$src_hash" "$dst_hash"
+	select_updated "$src_hash" "$src_hash_diff"
+}
+
+# write files in dst directory
+generate() {
+	while read -r k f; do
+		case "$k" in
+		copy) generate_copy "$f" ;;
+		file) generate_file "$f" ;;
+		html) generate_html "$f" ;;
+		md) generate_md "$f" ;;
+		sh) generate_sh "$f" ;;
+		template) info "template  $f" ;;
+		ignore) info "ignore    $f" ;;
+		*) info "unknown   $f" ;;
+		esac
+	done
+}
+
+# write src and dst hash files to dst directory
+write_hashes() {
+	if ! test -d "$DST"; then return; fi
+	echo "$1" >"$DST/$SSG_SRC"
+	echo "$2" | tee "$DST/$SSG_DST" | sha256 >&2
+}
+
+main() {
+	fail_no_args "${@}"
+	fail_no_src "${@}"
+
+	SRC=$(cd "$1" && pwd)
+	DST="$2"
+	SSG_IGNORE='.ssg.ignore'
+	SSG_TEMPLATE='.ssg.template'
+	SSG_SRC='.ssg.src'
+	SSG_DST='.ssg.dst'
+	NCPU=$(sysctl -n hw.ncpu 2>/dev/null || getconf NPROCESSORS_ONLN)
+
+	src_hash=$(hash_src)
+	select_src_files "$src_hash" | prepend_kind | generate
+	write_hashes "$src_hash" "$(hash_dst)"
+}
+
+main "${@}"
blob - /dev/null
blob + 81b21fe0ed41b084f56c25688252b3e98b8f95b8 (mode 755)
--- /dev/null
+++ ssg.test.sh
@@ -0,0 +1,636 @@
+#!/bin/ksh -e
+
+ok_count=0
+ok_expected=24
+
+plan() {
+	echo "$ok_expected..$ok_count"
+	test "$ok_expected" -eq "$ok_count" || { echo 'failed' && exit 1; }
+	echo 'passed' && exit 0
+}
+
+bench() {
+	i="$1" && shift
+	exec 3>&1
+	{
+		time (
+			n=1 && printf . >&3
+			while [ "$n" -le "$i" ]; do
+				"$@" >/dev/null 2>&1 && n=$((n + 1))
+				printf . >&3
+			done
+			printf '\n' >&3
+		)
+	} 2>&1 | grep 'real' | cut -f2 | cut -d' ' -f5
+	exec 3>&-
+}
+
+ok() { echo "ok: $*" && ok_count=$((ok_count + 1)); }
+not_ok() { echo "not ok: $*" && plan; }
+
+not_ok_diff_n() {
+	fifo=$(mktemp -u) || exit 1
+	mkfifo "$fifo" || exit 1
+	(printf "%s" "$2" >"$fifo") &
+	printf "\n%s\n" "$(cat)" | diff - "$fifo" || not_ok "$1"
+	rm -f "$fifo"
+}
+
+not_ok_diff() {
+	fifo=$(mktemp -u) || exit 1
+	mkfifo "$fifo" || exit 1
+	(echo "$2" >"$fifo") &
+	diff - "$fifo" || not_ok "$1"
+	rm -f "$fifo"
+}
+
+not_ok_find() {
+	find "$1" -type f | sort | sed "s,$1/,," | not_ok_diff_n "$2" "$3"
+}
+
+base=$(dirname "$0")
+cmd="$base/ssg.sh"
+test -x "$cmd" || { echo "$cmd not found" >&2 && exit 1; }
+
+basic_case() {
+	dir=$(mktemp -d)
+	src="$dir/src" && dst="$dir/dst"
+	mkdir "$src" "$src/.git"
+	echo '# h1' >"$src/markdown.md"
+	echo '<h1>h1</h1>' >"$src/html1.html"
+	echo '<html>' >"$src/html2.html"
+	echo '<title>{{title}}:{{site}}</title>{{content}}' >"$src/.ssg.template"
+	echo '.git/' >"$src/.ssg.ignore"
+	echo >"$src/.git/index"
+	echo >"$src/main.css"
+	echo >"$src/logo.png"
+	# shellcheck disable=2016
+	echo '
+if test -z "$1"; then echo "x.txt" && exit; fi
+echo . >"$2/x.txt" && echo "x.txt"
+' >"$src/.ssg.sh"
+	"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "basic_case" '
+ignore    .ssg.ignore
+sh        .ssg.sh > x.txt
+sh        .ssg.sh > x.txt.gz
+template  .ssg.template
+html      html1.html, .ssg.template > html1.html
+html      html1.html, .ssg.template > html1.html.gz
+html      html2.html
+html      html2.html > html2.html.gz
+copy      logo.png
+file      main.css
+file      main.css > main.css.gz
+md        markdown.md, .ssg.template > markdown.html
+md        markdown.md, .ssg.template > markdown.html.gz
+56fd1f7d1f2bcbe8b452073cc657b27a4eee72cab0e531de679788e5744af652
+'
+	rm -rf "$dir"
+}
+
+t() {
+	dir=$(mktemp -d) && src="$dir/src" && dst="$dir/dst"
+	case "$1" in
+
+	fail_no_args)
+		if "$cmd" "$src" 2>/dev/null; then
+			exit_code="$?" && test "$exit_code" -eq 1 ||
+				not_ok "$1: expected: exit code 1, actual: exit code $exit_code"
+		fi
+		test -d "$dst" && not_ok "$1"
+		;;
+
+	fail_no_src)
+		if "$cmd" "$src" "$dst" 2>/dev/null; then
+			exit_code="$?" && test "$exit_code" -eq 1 ||
+				not_ok "$1: expected: exit code 1, actual: exit code $exit_code"
+		fi
+		test -d "$dst" && not_ok "$1"
+		;;
+
+	select_src_files_empty_src)
+		mkdir "$src" "$dst" && "$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1" '
+01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b
+'
+		not_ok_find "$dst" "$1: dst not empty" '
+.ssg.dst
+.ssg.src
+'
+		;;
+
+	select_src_files_ssg_ignore)
+		mkdir "$src" "$src/a" "$src/a/b" "$src/a/c"
+		echo '1.txt' >"$src/.ssg.ignore"
+		echo '2.txt
+c/' >"$src/a/.ssg.ignore"
+		echo '3.txt' >"$src/a/b/.ssg.ignore"
+		echo >"$src/1.txt"
+		echo >"$src/a/2.txt"
+		echo >"$src/a/b/3.txt"
+		echo >"$src/a/c/4.txt"
+		echo >"$src/a/c/5.txt"
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1" '
+ignore    .ssg.ignore
+ignore    a/.ssg.ignore
+ignore    a/b/.ssg.ignore
+01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b
+'
+		not_ok_find "$dst" "$1" '
+.ssg.dst
+.ssg.src
+'
+		;;
+
+	select_src_files_no_dst)
+		mkdir "$src" && echo >"$src/t.png"
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1" '
+copy      t.png
+9db7b136bc6fdd9c51009ce2f88c69ff64060c3f3ff540a9199f37d2aa404eaa
+'
+		not_ok_find "$dst" "$1: dst has t.png" '
+.ssg.dst
+.ssg.src
+t.png
+'
+		;;
+
+	select_src_files_no_ssg_dst_ssg_src)
+		mkdir "$src" "$dst" && echo >"$src/t.png"
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: first run" '
+copy      t.png
+9db7b136bc6fdd9c51009ce2f88c69ff64060c3f3ff540a9199f37d2aa404eaa
+'
+		rm "$dst/.ssg.src"
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: second run" '
+copy      t.png
+9db7b136bc6fdd9c51009ce2f88c69ff64060c3f3ff540a9199f37d2aa404eaa
+'
+		rm "$dst/.ssg.dst"
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: third run" '
+copy      t.png
+9db7b136bc6fdd9c51009ce2f88c69ff64060c3f3ff540a9199f37d2aa404eaa
+'
+		;;
+
+	select_src_files_no_dst_ssg_dst_match)
+		mkdir "$src" "$dst" && echo >"$src/t.png"
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: first run" '
+copy      t.png
+9db7b136bc6fdd9c51009ce2f88c69ff64060c3f3ff540a9199f37d2aa404eaa
+'
+		echo x >>"$dst/.ssg.dst"
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: second run" '
+copy      t.png
+9db7b136bc6fdd9c51009ce2f88c69ff64060c3f3ff540a9199f37d2aa404eaa
+'
+		;;
+
+	select_src_files_no_src_diff)
+		mkdir "$src" "$dst" && echo >"$src/t.png"
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: first run" '
+copy      t.png
+9db7b136bc6fdd9c51009ce2f88c69ff64060c3f3ff540a9199f37d2aa404eaa
+'
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: second run" '
+9db7b136bc6fdd9c51009ce2f88c69ff64060c3f3ff540a9199f37d2aa404eaa
+'
+		;;
+
+	select_src_files_clean_dst)
+		mkdir "$src" && echo >"$src/t.png"
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: first run" '
+copy      t.png
+9db7b136bc6fdd9c51009ce2f88c69ff64060c3f3ff540a9199f37d2aa404eaa
+'
+		echo >"$dst/trash_file"
+		mkdir "$dst/trash_dir"
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: second run" '
+copy      t.png
+9db7b136bc6fdd9c51009ce2f88c69ff64060c3f3ff540a9199f37d2aa404eaa
+'
+		not_ok_find "$dst" "$1" '
+.ssg.dst
+.ssg.src
+t.png
+'
+		;;
+
+	select_src_files_clean_dst_dir)
+		mkdir "$src" "$src/dir"
+		echo >"$src/a.png"
+		echo >"$src/dir/b.png"
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: first run" '
+copy      a.png
+copy      dir/b.png
+6e0b941542f81e1b299a21444d0efe2fa224a4220e67df9c37cc34a2c6f01b13
+'
+		rm "$src/dir/b.png"
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: second run" '
+rm        dir/b.png
+rmdir     dir/
+e86615a87eeeae97fb6302dd5013109f0ccfb7336f164a39457e684c30bae90e
+'
+		not_ok_find "$dst" "$1" '
+.ssg.dst
+.ssg.src
+a.png
+'
+		;;
+
+	select_updated)
+		mkdir "$src" "$dst"
+		echo '<title></title>' >"$src/.ssg.template"
+		echo '<h1>h1</h1>' >"$src/html1.html"
+		echo '<html>' >"$src/html2.html"
+		echo '# h1' >"$src/markdown.md"
+		echo >"$src/t.png"
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: first run" '
+template  .ssg.template
+html      html1.html, .ssg.template > html1.html
+html      html1.html, .ssg.template > html1.html.gz
+html      html2.html
+html      html2.html > html2.html.gz
+md        markdown.md, .ssg.template > markdown.html
+md        markdown.md, .ssg.template > markdown.html.gz
+copy      t.png
+5538af7d3a6abc2aad8609b32f32154ea5d23109e2630399fecd0755ebde544f
+'
+		expected_dst='
+.ssg.dst
+.ssg.src
+html1.html
+html1.html.gz
+html2.html
+html2.html.gz
+markdown.html
+markdown.html.gz
+t.png
+'
+		not_ok_find "$dst" "$1" "$expected_dst"
+
+		echo 'x' >"$src/.ssg.template"
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: second run" '
+template  .ssg.template
+html      html1.html, .ssg.template > html1.html
+html      html1.html, .ssg.template > html1.html.gz
+md        markdown.md, .ssg.template > markdown.html
+md        markdown.md, .ssg.template > markdown.html.gz
+c3f5b69d372ad66211a052e7b572715941522aae7779eff22cf7becc1c8d18e8
+'
+		not_ok_find "$dst" "$1" "$expected_dst"
+		;;
+
+	generate_copy)
+		mkdir "$src" "$dst" && echo 'png' >"$src/t.png"
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: first run" '
+copy      t.png
+5265fde36fa46d08d2bc48d0f413d41c166ee966a4f94b5fd7ad0c23e1bb92d4
+'
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: second run" '
+5265fde36fa46d08d2bc48d0f413d41c166ee966a4f94b5fd7ad0c23e1bb92d4
+'
+		not_ok_find "$dst" "$1" '
+.ssg.dst
+.ssg.src
+t.png
+'
+		cat "$dst/t.png" | not_ok_diff "$1" 'png'
+		;;
+
+	generate_file)
+		mkdir "$src" "$dst" && echo 'txt' >"$src/t.txt"
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: first run" '
+file      t.txt
+file      t.txt > t.txt.gz
+482d02d3fdd5ca854ffc9370f9cf3d4efa5bb640713c90dcf5c9800d5acf6812
+'
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: second run" '
+482d02d3fdd5ca854ffc9370f9cf3d4efa5bb640713c90dcf5c9800d5acf6812
+'
+		not_ok_find "$dst" "$1" '
+.ssg.dst
+.ssg.src
+t.txt
+t.txt.gz
+'
+		cat "$dst/t.txt" | not_ok_diff "$1" 'txt'
+		hexdump -C "$dst/t.txt.gz" | not_ok_diff_n "$1" '
+00000000  1f 8b 08 00 00 00 00 00  02 03 2b a9 28 e1 02 00  |..........+.(...|
+00000010  d3 84 7d 34 04 00 00 00                           |..}4....|
+00000018
+'
+		;;
+
+	generate_html)
+		mkdir "$src" "$dst" && echo '<html>' >"$src/h.html"
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: first run" '
+html      h.html
+html      h.html > h.html.gz
+d167244df661961bfe78dd5e1b2c8c563cd588f3583a6438f2af3207401fb10c
+'
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: second run" '
+d167244df661961bfe78dd5e1b2c8c563cd588f3583a6438f2af3207401fb10c
+'
+		not_ok_find "$dst" "$1" '
+.ssg.dst
+.ssg.src
+h.html
+h.html.gz
+'
+		cat "$dst/h.html" | not_ok_diff "$1" '<html>'
+		hexdump -C "$dst/h.html.gz" | not_ok_diff_n "$1" '
+00000000  1f 8b 08 00 00 00 00 00  02 03 b3 c9 28 c9 cd b1  |............(...|
+00000010  e3 02 00 99 34 cb 33 07  00 00 00                 |....4.3....|
+0000001b
+'
+		;;
+
+	generate_html_with_template)
+		mkdir "$src" "$dst"
+		echo '<h1>h1</h1>' >"$src/h.html"
+		echo '<title>{{title}}~{{site}}</title>{{content}}' >"$src/.ssg.template"
+
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: first run" '
+template  .ssg.template
+html      h.html, .ssg.template > h.html
+html      h.html, .ssg.template > h.html.gz
+7cbe380c112e232fa4b618b1837d11c47abdfd01863ae484fdb411ade0f43af1
+'
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: second run" '
+7cbe380c112e232fa4b618b1837d11c47abdfd01863ae484fdb411ade0f43af1
+'
+		not_ok_find "$dst" "$1" '
+.ssg.dst
+.ssg.src
+h.html
+h.html.gz
+'
+
+		cat "$dst/h.html" | not_ok_diff "$1" '<title>h1~src</title><h1>h1</h1>'
+		hexdump -C "$dst/h.html.gz" | not_ok_diff_n "$1" '
+00000000  1f 8b 08 00 00 00 00 00  02 03 b3 29 c9 2c c9 49  |...........).,.I|
+00000010  b5 cb 30 ac 2b 2e 4a b6  d1 87 f0 6c 32 0c 81 22  |..0.+.J....l2.."|
+00000020  36 fa 40 8a 0b 00 1e b5  6d 4c 21 00 00 00        |6.@.....mL!...|
+0000002e
+'
+		;;
+
+	generate_html_with_template_no_title)
+		mkdir "$src" "$dst"
+		echo '<h1>h1</h1>' >"$src/h.html"
+		echo 'p' >"$src/p.html"
+		echo '<title>{{#title}}{{title}}: {{/title}}{{site}}</title>{{content}}' >"$src/.ssg.template"
+
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: first run" '
+template  .ssg.template
+html      h.html, .ssg.template > h.html
+html      h.html, .ssg.template > h.html.gz
+html      p.html, .ssg.template > p.html
+html      p.html, .ssg.template > p.html.gz
+816fcd36b20ed6114d6e13c15182ae33186676aa091558dce61f66c8e43c8b62
+'
+		not_ok_find "$dst" "$1" '
+.ssg.dst
+.ssg.src
+h.html
+h.html.gz
+p.html
+p.html.gz
+'
+
+		cat "$dst/h.html" | not_ok_diff "$1" '<title>h1: src</title><h1>h1</h1>'
+		cat "$dst/p.html" | not_ok_diff "$1" '<title>src</title>p'
+		;;
+
+
+	generate_html_with_template_in_dir)
+		mkdir "$src" "$src/dir"
+		echo >"$src/h1.html"
+		echo >"$src/dir/h2.html"
+		echo '/' >"$src/.ssg.template"
+		echo '/dir' >"$src/dir/.ssg.template"
+
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: first run" '
+template  .ssg.template
+template  dir/.ssg.template
+html      dir/h2.html, dir/.ssg.template > dir/h2.html
+html      dir/h2.html, dir/.ssg.template > dir/h2.html.gz
+html      h1.html, .ssg.template > h1.html
+html      h1.html, .ssg.template > h1.html.gz
+8602b68b3b149d967e43d9c4948099d4cbbc3edd2f0a5e46f01c37af78ba8506
+'
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: second run" '
+8602b68b3b149d967e43d9c4948099d4cbbc3edd2f0a5e46f01c37af78ba8506
+'
+		not_ok_find "$dst" "$1" '
+.ssg.dst
+.ssg.src
+dir/h2.html
+dir/h2.html.gz
+h1.html
+h1.html.gz
+'
+
+		cat "$dst/h1.html" | not_ok_diff "$1" '/'
+		cat "$dst/dir/h2.html" | not_ok_diff "$1" '/dir'
+		;;
+
+	generate_html_template_not_found)
+		mkdir "$src" "$dst"
+		echo '<h1>h1</h1>' >"$src/h.html"
+
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: first run" '
+html      h.html
+html      h.html > h.html.gz
+255d20dac0c5587bd5499827d3528db1433d60c04168ca2d9b2427de0f9a440e
+'
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: second run" '
+255d20dac0c5587bd5499827d3528db1433d60c04168ca2d9b2427de0f9a440e
+'
+		not_ok_find "$dst" "$1" '
+.ssg.dst
+.ssg.src
+h.html
+h.html.gz
+'
+
+		cat "$dst/h.html" | not_ok_diff "$1" '<h1>h1</h1>'
+		hexdump -C "$dst/h.html.gz" | not_ok_diff_n "$1" '
+00000000  1f 8b 08 00 00 00 00 00  02 03 b3 c9 30 b4 cb 30  |............0..0|
+00000010  b4 d1 07 52 5c 00 12 f0  3b a6 0c 00 00 00        |...R\...;.....|
+0000001e
+'
+		;;
+
+	generate_md_with_collision)
+		mkdir "$src" "$dst"
+		echo >"$src/h.md"
+		echo >"$src/h.html"
+
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: first run" '
+html      h.html
+html      h.html > h.html.gz
+fail: h.md collides with h.html
+'
+		;;
+
+	generate_md_with_template)
+		mkdir "$src" "$dst"
+		echo '# h1' >"$src/h.md"
+		echo '<title>{{title}}~{{site}}</title>{{content}}' >"$src/.ssg.template"
+
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: first run" '
+template  .ssg.template
+md        h.md, .ssg.template > h.html
+md        h.md, .ssg.template > h.html.gz
+916917ec6944394281526a5ba9276e39bdff828105b965188c168c847065be63
+'
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: second run" '
+916917ec6944394281526a5ba9276e39bdff828105b965188c168c847065be63
+'
+		not_ok_find "$dst" "$1" '
+.ssg.dst
+.ssg.src
+h.html
+h.html.gz
+'
+
+		cat "$dst/h.html" |
+			not_ok_diff "$1" '<title>h1~src</title><h1 id="h1">h1</h1>'
+		hexdump -C "$dst/h.html.gz" | not_ok_diff_n "$1" '
+00000000  1f 8b 08 00 00 00 00 00  02 03 b3 29 c9 2c c9 49  |...........).,.I|
+00000010  b5 cb 30 ac 2b 2e 4a b6  d1 87 f0 6c 32 0c 15 32  |..0.+.J....l2..2|
+00000020  53 6c 95 32 0c 95 80 32  36 fa 19 86 76 5c 00 0b  |Sl.2...26...v\..|
+00000030  26 40 c1 29 00 00 00                              |&@.)...|
+00000037
+'
+		;;
+
+	generate_md_template_not_found)
+		mkdir "$src" "$dst"
+		echo '# h1' >"$src/h.md"
+
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: first run" '
+md        h.md > h.html
+md        h.md > h.html.gz
+2f33f1e74aa7d6f33502cc842f2001a7759da120f0fb0f84a6295c1fe81d0319
+'
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: second run" '
+2f33f1e74aa7d6f33502cc842f2001a7759da120f0fb0f84a6295c1fe81d0319
+'
+		not_ok_find "$dst" "$1" '
+.ssg.dst
+.ssg.src
+h.html
+h.html.gz
+'
+
+		cat "$dst/h.html" | not_ok_diff "$1" '<h1 id="h1">h1</h1>'
+		hexdump -C "$dst/h.html.gz" | not_ok_diff_n "$1" '
+00000000  1f 8b 08 00 00 00 00 00  02 03 b3 c9 30 54 c8 4c  |............0T.L|
+00000010  b1 55 ca 30 54 b2 cb 30  b4 d1 cf 30 b4 e3 02 00  |.U.0T..0...0....|
+00000020  0e 5d 6f 38 14 00 00 00                           |.]o8....|
+00000028
+'
+		;;
+
+	generate_sh)
+		mkdir "$src"
+		# shellcheck disable=2016
+		echo '
+if test -z "$1"; then echo "x.txt" && exit; fi
+echo . >"$2/x.txt" && echo "x.txt"
+' >"$src/.ssg.sh"
+
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: first run" '
+sh        .ssg.sh > x.txt
+sh        .ssg.sh > x.txt.gz
+99c418b0dcd6c6c2124e87b4857b415bcf0a12ba7c7540d8ac53fe73c2046a29
+'
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: second run" '
+99c418b0dcd6c6c2124e87b4857b415bcf0a12ba7c7540d8ac53fe73c2046a29
+'
+		not_ok_find "$dst" "$1" '
+.ssg.dst
+.ssg.src
+x.txt
+x.txt.gz
+'
+
+		cat "$dst/x.txt" | not_ok_diff "$1" '.'
+		hexdump -C "$dst/x.txt.gz" | not_ok_diff_n "$1" '
+00000000  1f 8b 08 00 00 00 00 00  02 03 d3 e3 02 00 cd f2  |................|
+00000010  0b aa 02 00 00 00                                 |......|
+00000016
+'
+		;;
+
+	generate_sh_with_collision)
+		mkdir "$src"
+		echo >"$src/x.txt"
+		# shellcheck disable=2016
+		echo '
+if test -z "$1"; then echo "x.txt" && exit; fi
+echo . >"$2/x.txt" && echo "x.txt"
+' >"$src/.ssg.sh"
+
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: first run" '
+fail: .ssg.sh collides with x.txt
+'
+		;;
+
+	write_hashes)
+		mkdir "$src"
+		echo >"$src/x.txt"
+		# shellcheck disable=2016
+		"$cmd" "$src" "$dst" 2>&1 | not_ok_diff_n "$1: first run" '
+file      x.txt
+file      x.txt > x.txt.gz
+a12d7b67f235edb37cfcf1bdd5a50a2e0486e1612eda28210b816eaff424a100
+'
+		cat "$dst/.ssg.src" | not_ok_diff_n "$1" '
+01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b x.txt
+'
+		cat "$dst/.ssg.dst" | not_ok_diff_n "$1" '
+01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b x.txt
+34d5848c995803cfd00c2f7f02d807e2069b51ffca60a40f05d2d7229ef13b69 x.txt.gz
+'
+		;;
+
+	*) not_ok "$1: not such test" ;;
+
+	esac
+
+	ok "$1" && rm -rf "$dir"
+}
+
+# tests
+
+t fail_no_args
+t fail_no_src
+t select_src_files_empty_src
+t select_src_files_ssg_ignore
+t select_src_files_no_dst
+t select_src_files_no_ssg_dst_ssg_src
+t select_src_files_no_dst_ssg_dst_match
+t select_src_files_no_src_diff
+t select_src_files_clean_dst
+t select_src_files_clean_dst_dir
+t select_updated
+t generate_copy
+t generate_file
+t generate_html
+t generate_html_with_template
+t generate_html_with_template_no_title
+t generate_html_with_template_in_dir
+t generate_html_template_not_found
+t generate_md_with_collision
+t generate_md_with_template
+t generate_md_template_not_found
+t generate_sh
+t generate_sh_with_collision
+t write_hashes
+
+basic_case && bench 4 basic_case
+
+plan