Project Notes

arachnopress

zero-JS, cookie-free, monolithic static site generator

Missingarachnopress_1.0.19.tar.gzgzipped source archive

DESCRIPTION

arachnopress builds a zero-JS, cookie-free static site from simple hand-written HTML fragments within subdirectories of articles/. It has no Markdown layer or client-side runtime, and provides freedom to include any HTML content the user requires.

The build writes a single index.html with dynamic theming, flexible navigation and layout suitable for desktop or mobile, sortable article and section indexes, a static contact form, image and download blocks, and collapsible code blocks with plain-text or optional server-side syntax highlighting (escaped/tokenized at build time).

All dynamic features are provided using only the CSS within styles.css (provided as-is) and nav-sections.css (created at build time).

The build is broadly POSIX-compatible, using sh, make, and standard Unix tools, supporting BSD, GNU/Linux, macOS, and others. The only known POSIX exceptions are: mktemp (broadly available) and the optional syntax highlighters, pygments or source-highlight.

FILES

Source articles are read from articles/. Generated index.html and nav-sections.css are written at the project root.

Project directory treeraw
.
|-- Makefile
|-- index.html
|-- nav-sections.css
|-- styles.css
|-- tools
|   |-- build.sh
|   |-- html-fragment.outlang
|   |-- license.txt
|   `-- theme-menu.html
`-- articles
    `-- article-name
        |-- article.html
        |-- example.c
        `-- image.png
MakefileBuild entry point and user settingsraw
.PHONY: build

# Build settings may be supplied through the environment or as command-line
# make variables. Their defaults live in tools/build.sh so values never need
# to be interpolated into shell source here.
build:
	sh tools/build.sh
tools/build.shPOSIX shell generatorraw
#!/bin/sh
set -eu

LC_ALL=C
export LC_ALL
unset SOURCE_HIGHLIGHT_DATADIR

root=$(CDPATH= cd "$(dirname "$0")/.." && pwd)
articles_dir=$root/articles
outlang=$root/tools/html-fragment.outlang
theme_menu=$root/tools/theme-menu.html
styles_file=$root/styles.css
license_file=${LICENSE_FILE:-$root/tools/license.txt}
site_title=${SITE_TITLE:-arachnopress}
site_mark=${SITE_MARK:-}
site_mark_color=${SITE_MARK_COLOR:-#2aa198}
generator_name=${GENERATOR_NAME:-arachnopress}
generator_version=${GENERATOR_VERSION:-1.0.19}
build_date=$(date -u '+%Y-%m-%d %H:%M UTC')
build_day=$(date -u '+%Y-%m-%d')
copyright_year=$(date -u '+%Y')
build_user=$(id -un 2>/dev/null || printf unknown)
build_host=$(uname -n 2>/dev/null || printf localhost)
copyright_holder=${COPYRIGHT_HOLDER:-$build_user@$build_host}
default_theme=${DEFAULT_THEME:-solarized-dark}
article_order=${ARTICLE_ORDER:-title}
highlighter=${HIGHLIGHTER:-pygments}
code_footer_lines=${CODE_FOOTER_LINES:-23}
pygmentize=$(command -v pygmentize 2>/dev/null || :)
source_highlight=$(command -v source-highlight 2>/dev/null || :)
sha256_bin=$(command -v sha256 2>/dev/null || :)
sha256sum_bin=$(command -v sha256sum 2>/dev/null || :)
shasum_bin=$(command -v shasum 2>/dev/null || :)
openssl_bin=$(command -v openssl 2>/dev/null || :)
tmp=$(mktemp -d "${TMPDIR:-/tmp}/arachnopress-build.XXXXXXXXXX")
output_tmp=
nav_tmp=
attrs_file=$tmp/attrs.txt
tab=$(printf '\t')
block_index=0
highlighted_blocks=0
raw_code_blocks=0

cleanup() {
	rm -rf "$tmp"
	[ -z "$output_tmp" ] || rm -f "$output_tmp"
	[ -z "$nav_tmp" ] || rm -f "$nav_tmp"
}

on_signal() {
	trap - HUP INT TERM
	exit 1
}

trap cleanup EXIT
trap on_signal HUP INT TERM

die() {
	printf '%s\n' "$*" >&2
	exit 1
}

html_escape() {
	awk '{
		gsub(/&/, "\\&")
		gsub(/</, "\\&lt;")
		gsub(/>/, "\\&gt;")
		print
	}' "$@"
}

html_escape_attr() {
	printf '%s\n' "$1" | awk '{
		gsub(/&/, "\\&amp;")
		gsub(/</, "\\&lt;")
		gsub(/>/, "\\&gt;")
		gsub(/"/, "\\&quot;")
		printf "%s", $0
	}'
}

sanitize_identifier() {
	printf '%s' "$1" |
		tr -c 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-' '_' |
		sed -e 's/__*/_/g' -e 's/^_*//' -e 's/_*$//'
}

url_escape() {
	od -A n -t x1 -v | awk '{
		for (i = 1; i <= NF; i++)
			printf "%%%s", $i
	}'
}

favicon_href() {
	printf 'data:image/svg+xml,'
	{
		printf '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">'
		printf '<text x="50" y="76" text-anchor="middle" font-size="82" fill="'
		html_escape_attr "$site_mark_color"
		printf '">'
		html_escape_attr "$1"
		printf '</text></svg>'
	} |
		url_escape
}

license_escape() {
	env ARACHNOPRESS_COPYRIGHT_HOLDER="$copyright_holder" \
	awk -v year="$copyright_year" '
		function replace(s, needle, value,   p, out) {
			out = ""
			while (p = index(s, needle)) {
				out = out substr(s, 1, p - 1) value
				s = substr(s, p + length(needle))
			}
			return out s
		}
		BEGIN {
			holder = ENVIRON["ARACHNOPRESS_COPYRIGHT_HOLDER"]
		}
		{
			line = replace($0, "<YEAR>", year)
			line = replace(line, "<COPYRIGHT_HOLDER>", holder)
			gsub(/&/, "\\&amp;", line)
			gsub(/</, "\\&lt;", line)
			gsub(/>/, "\\&gt;", line)
			print line
		}
	' "$license_file"
}

attr() {
	name=$1
	printf '%s\n' "$2" |
	awk -v name="$name" '
		{
			line = $0
			p = index(line, " " name "=\"")
			if (!p)
				p = index(line, "\t" name "=\"")
			if (!p)
				exit
			s = substr(line, p + length(name) + 3)
			q = index(s, "\"")
			if (q)
				print substr(s, 1, q - 1)
			exit
		}
	'
}

write_attrs() {
	line=$1
	out=$2
	shift 2
	names=$*

	printf '%s\n' "$line" |
	awk -v names="$names" '
		function decode(s) {
			gsub(/&quot;|&#34;/, "\"", s)
			gsub(/&apos;|&#39;/, "\047", s)
			gsub(/&lt;|&#60;/, "<", s)
			gsub(/&gt;|&#62;/, ">", s)
			gsub(/&amp;|&#38;/, "\\&", s)
			return s
		}
		function attr(field,   p, s, q) {
			p = index(line, " " field "=\"")
			if (!p)
				p = index(line, "\t" field "=\"")
			if (!p)
				return ""
			s = substr(line, p + length(field) + 3)
			q = index(s, "\"")
			if (!q)
				return ""
			return decode(substr(s, 1, q - 1))
		}
		{
			line = $0
			count = split(names, fields, /[[:space:]][[:space:]]*/)
			for (i = 1; i <= count; i++)
				print attr(fields[i])
			exit
		}
	' > "$out"
}

normalize_lang() {
	case $1 in
		'') printf '' ;;
		auto|guess) printf 'auto' ;;
		text|txt|plain|none|nohilite) printf 'text' ;;
		patch|udiff) printf 'diff' ;;
		makefile|mf) printf 'make' ;;
		*) printf '%s' "$1" ;;
	esac
}

details_open_attr() {
	case $1 in
		0|false|no|closed|collapsed) printf '' ;;
		*) printf ' open' ;;
	esac
}

show_footer() {
	case $1 in
		0|false|no|off|hidden|hide) return 1 ;;
		*) return 0 ;;
	esac
}

show_header() {
	case $1 in
		0|false|no|off|hidden|hide) return 1 ;;
		*) return 0 ;;
	esac
}

show_explicit_footer() {
	case $1 in
		1|true|yes|on|shown|show) return 0 ;;
		*) return 1 ;;
	esac
}

file_line_count_le() {
	awk -v limit="$2" 'END { exit !(NR <= limit) }' "$1"
}

next_block_id() {
	while :; do
		block_index=$((block_index + 1))
		block_id=block-$block_index
		block_footer_id=$block_id-footer
		if ! grep -F -x -q -e "$block_id" -e "$block_footer_id" "$anchor_ids"; then
			return
		fi
	done
}

format_size() {
	bytes=$(wc -c < "$1")
	awk -v bytes="$bytes" '
		BEGIN {
			split("B KiB MiB GiB TiB", unit)
			size = bytes + 0
			i = 1
			while (size >= 1024 && i < 5) {
				size /= 1024
				i++
			}
			if (i == 1)
				printf "%d %s", size, unit[i]
			else if (size < 10)
				printf "%.1f %s", size, unit[i]
			else
				printf "%.0f %s", size, unit[i]
		}
	'
}

print_sha256() {
	printf '%s\n' "$1" |
	awk '
		length($1) == 64 && $1 ~ /^[0-9A-Fa-f]+$/ {
			print $1
			found = 1
		}
		END { exit found ? 0 : 1 }
	'
}

file_sha256() {
	file=$1
	hash=

	if [ -n "$sha256_bin" ]; then
		hash=$("$sha256_bin" -q "$file" 2>/dev/null) &&
			print_sha256 "$hash" && return 0
	fi
	if [ -n "$sha256sum_bin" ]; then
		hash=$("$sha256sum_bin" "$file" 2>/dev/null) &&
			print_sha256 "$hash" && return 0
	fi
	if [ -n "$shasum_bin" ]; then
		hash=$("$shasum_bin" -a 256 "$file" 2>/dev/null) &&
			print_sha256 "$hash" && return 0
	fi
	if [ -n "$openssl_bin" ]; then
		hash=$("$openssl_bin" dgst -sha256 -r "$file" 2>/dev/null) &&
			print_sha256 "$hash" && return 0
	fi

	return 1
}

safe_name() {
	case $1 in
		''|/*|*/*|..|*[!A-Za-z0-9._-]*) return 1 ;;
	esac
}

reserve_anchor() {
	id=$1
	context=$2

	safe_name "$id" || die "Unsafe HTML id in $context: $id"
	if grep -F -x -q -e "$id" "$anchor_ids"; then
		die "Duplicate HTML id: $id"
	fi
	printf '%s\n' "$id" >> "$anchor_ids"
}

safe_file_path() {
	dir=$1
	src=$2
	rest=$src
	path=

	case $src in
		''|/*|*//*) return 1 ;;
	esac

	while :; do
		case $rest in
			*/*)
				part=${rest%%/*}
				rest=${rest#*/}
				;;
			*)
				part=$rest
				rest=
				;;
		esac

		case $part in
			''|.|..|*[!A-Za-z0-9._-]*) return 1 ;;
		esac

		path=${path:+$path/}$part
		[ ! -L "$dir/$path" ] || return 1
		[ -n "$rest" ] || break
	done
}

safe_lang() {
	case $1 in
		-*|*[!A-Za-z0-9_+-]*) return 1 ;;
	esac
}

safe_color() {
	case $1 in
		\#[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]|\#[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]) return 0 ;;
	esac

	return 1
}

safe_date() {
	case $1 in
		*"$tab"*) return 1 ;;
		[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*) return 0 ;;
	esac

	return 1
}

normalize_theme() {
	case $1 in
		theme-*) printf '%s' "$1" ;;
		*) printf 'theme-%s' "$1" ;;
	esac
}

sort_articles() {
	case $1 in
		title) sort -f -t "$tab" -k 2,2 -k 1,1 "$article_meta" ;;
		modified) sort -t "$tab" -k 3,3r -k 1,1 "$article_meta" ;;
		created) sort -t "$tab" -k 4,4r -k 1,1 "$article_meta" ;;
	esac
}

slugify() {
	printf '%s\n' "$1" |
		tr '[:upper:]' '[:lower:]' |
		sed -e 's/[^a-z0-9][^a-z0-9]*/-/g' \
		    -e 's/^-//' \
		    -e 's/-$//' \
		    -e 's/^$/section/'
}

heading_text() {
	tag=$1
	printf '%s\n' "$2" |
	awk -v tag="$tag" '
		function decode(s) {
			gsub(/&quot;|&#34;/, "\"", s)
			gsub(/&apos;|&#39;/, "\047", s)
			gsub(/&lt;|&#60;/, "<", s)
			gsub(/&gt;|&#62;/, ">", s)
			gsub(/&amp;|&#38;/, "\\&", s)
			return s
		}
		{
			line = $0
			start = index(line, "<" tag)
			end = index(line, "</" tag ">")
			if (!start || !end || end < start)
				exit
			head = substr(line, start, end - start)
			for (i = 1; i <= length(head); i++)
				if (substr(head, i, 1) == ">") {
					close_pos = i
					break
				}
			if (!close_pos)
				exit
			text = substr(head, close_pos + 1)
			if (text !~ /</)
				print decode(text)
			exit
		}
	'
}

unique_auto_anchor() {
	base=$1
	id=$base
	n=1

	while grep -F -x -q -e "$id" "$anchor_ids"; do
		n=$((n + 1))
		id=$base-$n
	done

	printf '%s' "$id"
}

marker_empty() {
	line=$1
	tag=$2

	printf '%s\n' "$line" |
	awk -v tag="$tag" -v endtag="</$tag>" '
		{
			start = index($0, "<" tag)
			if (!start)
				exit 1
			prefix = substr($0, 1, start - 1)
			if (prefix !~ /^[[:space:]]*$/)
				exit 1
			pos = index($0, endtag)
			if (!pos || pos < start)
				exit 1
			before = substr($0, start, pos - start)
			after = substr($0, pos + length(endtag))
			open_end = 0
			for (i = 1; i <= length(before); i++)
				if (substr(before, i, 1) == ">")
					open_end = i
			if (!open_end)
				exit 1
			body = substr(before, open_end + 1)
			if (body !~ /^[[:space:]]*$/ || after !~ /^[[:space:]]*$/)
				exit 1
		}
	'
}

require_empty_marker() {
	source=$1
	line=$2
	tag=$3
	name=$4

	marker_empty "$line" "$tag" ||
		die "$name marker must be empty and contained on one line: $source"
}

read_sections() {
	source=$1
	slug=$2
	heading_meta=$tmp/headings-$slug.tsv
	section_ids=$tmp/section-ids-$slug.txt
	subsection_meta=$tmp/subsections-$slug.tsv
	subsection_parents=$tmp/subsection-parents-$slug.txt
	: > "$heading_meta"
	: > "$section_ids"
	: > "$subsection_meta"
	: > "$subsection_parents"
	current_section=

	while IFS= read -r line || [ -n "$line" ]; do
		case $line in
			*'<h2'*)
				heading_tag=h2
				closing='</h2>'
				;;
			*'<h3'*)
				heading_tag=h3
				closing='</h3>'
				;;
			*) continue ;;
		esac
		case $line in
			*"$closing"*) ;;
			*) die "Indexable $heading_tag must be plain one-line text: $source" ;;
		esac

		heading_title=$(heading_text "$heading_tag" "$line")
		[ -n "$heading_title" ] ||
			die "Indexable $heading_tag must contain plain one-line text: $source"
		case $heading_title in
			*"$tab"*) die "Heading title may not contain tabs: $source" ;;
		esac

		heading_id=$(attr id "$line")
		if [ -n "$heading_id" ]; then
			safe_name "$heading_id" ||
				die "Unsafe $heading_tag id in $source: $heading_id"
		else
			case $heading_tag in
				h2) anchor_base=$slug-$(slugify "$heading_title") ;;
				h3)
					[ -n "$current_section" ] ||
						die "Indexable h3 must follow an h2: $source"
					anchor_base=$current_section-$(slugify "$heading_title")
					;;
			esac
			heading_id=$(unique_auto_anchor "$anchor_base")
		fi
		reserve_anchor "$heading_id" "$source"

		case $heading_tag in
			h2)
				current_section=$heading_id
				parent_id=
				printf '%s\n' "$heading_id" >> "$section_ids"
				;;
			h3)
				[ -n "$current_section" ] ||
					die "Indexable h3 must follow an h2: $source"
				parent_id=$current_section
				printf '%s\t%s\n' "$parent_id" "$heading_id" >> "$subsection_meta"
				if ! grep -F -x -q -e "$parent_id" "$subsection_parents"; then
					printf '%s\n' "$parent_id" >> "$subsection_parents"
				fi
				subsection_count=$((subsection_count + 1))
				;;
		esac
		printf '%s\t%s\t%s\t%s\n' \
			"$heading_tag" "$heading_id" "$heading_title" "$parent_id" >> "$heading_meta"
	done < "$source"
}

write_theme_menu() {
	awk -v theme_id="$theme_id" '
		/type="radio"/ && /name="theme"/ {
			gsub(/[[:space:]]checked/, "")
			if (index($0, "id=\"" theme_id "\""))
				sub(/>$/, " checked>")
		}
		{ print }
	' "$theme_menu"
}

optimize_highlighted_html() {
	sed \
		-e 's/<span class="w">\([[:space:]]*\)<\/span>/\1/g' \
		-e 's/<span class="sh-normal">\([[:space:]]*\)<\/span>/\1/g' \
		"$1"
}

highlight_code() {
	code_file=$1
	lang=$2
	highlight_output=$3
	unoptimized_file=$tmp/code-unoptimized.html

	[ "$lang" != text ] || return 1
	rm -f "$unoptimized_file"

	case $highlighter in
		pygments)
			highlight_pygments "$code_file" "$lang" "$unoptimized_file" || return 1
			;;
		source-highlight)
			highlight_source "$code_file" "$lang" "$unoptimized_file" || return 1
			;;
		none) return 1 ;;
	esac

	optimize_highlighted_html "$unoptimized_file" > "$highlight_output" || return 1
}

highlight_pygments() {
	code_file=$1
	lang=$2
	output_file=$3

	[ -n "$pygmentize" ] || return 1

	case $lang in
		'')
			"$pygmentize" -f html -O nowrap "$code_file" > "$output_file"
			;;
		auto)
			auto_lexer=$("$pygmentize" -N "$code_file" 2>/dev/null || printf text)
			case $auto_lexer in
				''|text)
					"$pygmentize" -f html -O nowrap -g "$code_file" > "$output_file"
					;;
				*)
					if safe_lang "$auto_lexer"; then
						"$pygmentize" -f html -O nowrap -l "$auto_lexer" "$code_file" > "$output_file"
					else
						"$pygmentize" -f html -O nowrap -g "$code_file" > "$output_file"
					fi
					;;
			esac
			;;
		*)
			"$pygmentize" -f html -O nowrap -l "$lang" "$code_file" > "$output_file"
			;;
	esac
}

highlight_source() {
	code_file=$1
	lang=$2
	output_file=$3

	[ -n "$source_highlight" ] && [ -f "$outlang" ] || return 1

	case $lang in
		''|auto)
			"$source_highlight" --no-doc --outlang-def="$outlang" \
				--input="$code_file" --output=STDOUT > "$output_file"
			;;
		make)
			"$source_highlight" --no-doc --outlang-def="$outlang" \
				--src-lang=makefile \
				--input="$code_file" --output=STDOUT > "$output_file"
			;;
		*)
			"$source_highlight" --no-doc --outlang-def="$outlang" \
				--src-lang="$lang" \
				--input="$code_file" --output=STDOUT > "$output_file"
			;;
	esac
}

render_missing_block() {
	message=$1
	title=$2
	note=$3
	header=$4

	if show_header "$header"; then
		printf '<details class="code-details" open>\n'
		render_block_summary "$title" "$note" '' '' ''
		class='code-block code-block-missing'
	else
		class='code-block code-block-missing code-block-standalone'
	fi
	printf '  <pre class="%s"><code>' "$class"
	printf '%s\n' "$message" | html_escape
	printf '</code></pre>\n'
	if show_header "$header"; then
		printf '</details>\n'
	fi
}

render_block_label() {
	title_html=$(html_escape_attr "$1")
	note_html=$(html_escape_attr "$2")

	printf '<span class="block-label"><code class="block-title">%s</code>' "$title_html"
	if [ -n "$2" ]; then
		printf '<span class="block-note">%s</span>' "$note_html"
	fi
	printf '</span>'
}

render_block_summary() {
	title=$1
	note=$2
	href=$3
	id=$4
	bottom=$5

	if [ -n "$id" ]; then
		printf '  <summary id="%s">' "$id"
	else
		printf '  <summary>'
	fi
	render_block_label "$title" "$note"
	if [ -n "$href" ]; then
		printf '<a class="block-action code-download" href="%s" download>raw</a>' "$href"
	fi
	if [ -n "$bottom" ]; then
		printf '<a class="block-action block-jump" href="#%s" aria-label="Bottom">↓</a>' "$bottom"
	fi
	printf '</summary>\n'
}

render_block_footer() {
	title=$1
	size=$2
	href=$3
	top=$4
	id=$5

	if [ -n "$id" ]; then
		printf '  <footer class="block-footer" id="%s">' "$id"
	else
		printf '  <footer class="block-footer">'
	fi
	render_block_label "$title" ''
	printf '<span class="block-size">%s</span>' "$size"
	if [ -n "$href" ]; then
		printf '<a class="block-action block-footer-link" href="%s" download>raw</a>' "$href"
	fi
	if [ -n "$top" ]; then
		printf '<a class="block-action block-footer-link block-jump" href="#%s" aria-label="Top">↑</a>' "$top"
	fi
	printf '</footer>\n'
}

render_code_block() {
	dir=$1
	src=$2
	lang=$(normalize_lang "$3")
	open_attr=$(details_open_attr "$4")
	title=$5
	note=$6
	footer=$7
	header=$8

	[ -n "$title" ] || title=$src
	if ! safe_file_path "$dir" "$src"; then
		render_missing_block "Unsafe code source path: $src" "$title" "$note" "$header"
		return
	fi
	if ! safe_lang "$lang"; then
		render_missing_block "Unsafe code language: $lang" "$title" "$note" "$header"
		return
	fi

	code_file=$dir/$src
	title_html=$(html_escape_attr "$title")
	raw_href=articles/${dir##*/}/$src
	lang_attr=
	highlight_tmp=$tmp/code.html

	[ -n "$lang" ] && lang_attr=" data-lang=\"$lang\""

	if [ ! -f "$code_file" ]; then
		render_missing_block "Missing code source: $src" "$title" "$note" "$header"
		return
	fi
	if show_header "$header"; then
		want_header=1
		if [ -z "$footer" ] &&
		    file_line_count_le "$code_file" "$code_footer_lines"; then
			footer=false
		fi
		if show_footer "$footer"; then
			want_footer=1
		else
			want_footer=0
		fi
	else
		want_header=0
		if show_explicit_footer "$footer"; then
			want_footer=1
		else
			want_footer=0
		fi
	fi

	if [ "$want_header" = 1 ]; then
		next_block_id
	fi
	if [ "$want_footer" = 1 ]; then
		size=$(format_size "$code_file")
	fi
	rm -f "$highlight_tmp"
	case $highlighter in
		none)
			raw_code_blocks=$((raw_code_blocks + 1))
			;;
		*)
			if highlight_code "$code_file" "$lang" "$highlight_tmp" 2>/dev/null &&
			   { [ ! -s "$code_file" ] || [ -s "$highlight_tmp" ]; }; then
				highlighted_blocks=$((highlighted_blocks + 1))
			else
				rm -f "$highlight_tmp"
				raw_code_blocks=$((raw_code_blocks + 1))
			fi
			;;
	esac

	if [ "$want_header" = 1 ]; then
		printf '<details class="code-details"%s>\n' "$open_attr"
		if [ "$want_footer" = 1 ]; then
			render_block_summary "$title" "$note" "$raw_href" "$block_id" "$block_id-footer"
		else
			render_block_summary "$title" "$note" "$raw_href" "$block_id" ''
		fi
		block_class=code-block
	elif [ "$want_footer" = 1 ]; then
		printf '<div class="block-standalone">\n'
		block_class=code-block
	else
		block_class='code-block code-block-standalone'
	fi
	printf '  <pre class="%s" data-src="%s" data-title="%s"%s><code>' \
		"$block_class" "$src" "$title_html" "$lang_attr"
	if [ -f "$highlight_tmp" ]; then
		cat "$highlight_tmp"
	else
		html_escape "$code_file"
	fi
	printf '</code></pre>\n'
	if [ "$want_footer" = 1 ]; then
		if [ "$want_header" = 1 ]; then
			render_block_footer "$title" "$size" "$raw_href" "$block_id" "$block_id-footer"
		else
			render_block_footer "$title" "$size" "$raw_href" '' ''
		fi
	fi
	if [ "$want_header" = 1 ]; then
		printf '</details>\n'
	elif [ "$want_footer" = 1 ]; then
		printf '</div>\n'
	fi
}

render_missing_image() {
	message=$1
	title=$2
	note=$3
	header=$4

	if show_header "$header"; then
		printf '<details class="media-details" open>\n'
		render_block_summary "$title" "$note" '' '' ''
		class='image-block image-block-missing'
	else
		class='image-block image-block-missing image-block-standalone'
	fi
	printf '  <figure class="%s">\n' "$class"
	printf '    <p>'
	printf '%s\n' "$message" | html_escape
	printf '</p>\n'
	printf '  </figure>\n'
	if show_header "$header"; then
		printf '</details>\n'
	fi
}

render_image_block() {
	dir=$1
	src=$2
	alt=$3
	caption=$4
	open_attr=$(details_open_attr "$5")
	title=$6
	note=$7
	footer=$8
	header=$9

	[ -n "$title" ] || title=$src
	if ! safe_file_path "$dir" "$src"; then
		render_missing_image "Unsafe image source path: $src" "$title" "$note" "$header"
		return
	fi

	image_file=$dir/$src
	image_href=articles/${dir##*/}/$src
	alt_html=$(html_escape_attr "$alt")
	caption_html=$(html_escape_attr "$caption")

	if [ ! -f "$image_file" ]; then
		render_missing_image "Missing image source: $src" "$title" "$note" "$header"
		return
	fi

	if show_header "$header"; then
		want_header=1
		next_block_id
		if show_footer "$footer"; then
			want_footer=1
		else
			want_footer=0
		fi
	else
		want_header=0
		if show_explicit_footer "$footer"; then
			want_footer=1
		else
			want_footer=0
		fi
	fi
	if [ "$want_footer" = 1 ]; then
		size=$(format_size "$image_file")
	fi
	if [ "$want_header" = 1 ]; then
		printf '<details class="media-details"%s>\n' "$open_attr"
		if [ "$want_footer" = 1 ]; then
			render_block_summary "$title" "$note" "$image_href" "$block_id" "$block_id-footer"
		else
			render_block_summary "$title" "$note" "$image_href" "$block_id" ''
		fi
		block_class=image-block
	elif [ "$want_footer" = 1 ]; then
		printf '<div class="block-standalone">\n'
		block_class=image-block
	else
		block_class='image-block image-block-standalone'
	fi
	printf '  <figure class="%s">\n' "$block_class"
	printf '    <img src="%s" alt="%s" loading="lazy" decoding="async">\n' "$image_href" "$alt_html"
	if [ -n "$caption" ]; then
		printf '    <figcaption>%s</figcaption>\n' "$caption_html"
	fi
	printf '  </figure>\n'
	if [ "$want_footer" = 1 ]; then
		if [ "$want_header" = 1 ]; then
			render_block_footer "$title" "$size" "$image_href" "$block_id" "$block_id-footer"
		else
			render_block_footer "$title" "$size" "$image_href" '' ''
		fi
	fi
	if [ "$want_header" = 1 ]; then
		printf '</details>\n'
	elif [ "$want_footer" = 1 ]; then
		printf '</div>\n'
	fi
}

render_download() {
	dir=$1
	src=$2
	title=$3
	note=$4

	[ -n "$title" ] || title=$src
	title_html=$(html_escape_attr "$title")
	note_html=$(html_escape_attr "$note")
	href=articles/${dir##*/}/$src
	file=$dir/$src
	printf '<div class="article-downloads">\n'
	if safe_file_path "$dir" "$src" && [ -f "$file" ]; then
		size=$(format_size "$file")
		checksum=$(file_sha256 "$file" || :)
		printf '  <div class="article-download">'
		printf '<a class="article-download-hit" href="%s" download aria-label="Download %s"></a>' \
			"$href" "$title_html"
		printf '<span class="article-download-kind">Download</span>'
		render_block_label "$title" "$note"
		printf '<span class="block-size">%s</span>' "$size"
		if [ -n "$checksum" ]; then
			printf '<span class="block-checksum">SHA256 (%s) = %s</span>' \
				"$src" "$checksum"
		fi
		printf '</div>\n'
	else
		printf '  <span class="article-download article-download-missing">'
		printf '<span class="article-download-kind">Missing</span>'
		printf '<span class="block-label"><code class="block-title">%s</code>' "$title_html"
		if [ -n "$note" ]; then
			printf '<span class="block-note">%s</span>' "$note_html"
		fi
		printf '</span></span>\n'
	fi
	printf '</div>\n'
}

render_article() {
	dir=$1
	source=$2
	slug=${dir##*/}
	heading_meta=$tmp/headings-$slug.tsv

	exec 3< "$heading_meta"
	while IFS= read -r line || [ -n "$line" ]; do
		case $line in
			*'<div'*'class="article-downloads"'*)
				require_empty_marker "$source" "$line" div "Download"
				case $line in
					*'data-downloads='*)
						die "Download marker uses obsolete data-downloads; use one data-src per download: $source"
						;;
				esac
				write_attrs "$line" "$attrs_file" \
					data-src data-title data-note
				{
					IFS= read -r data_src
					IFS= read -r data_title
					IFS= read -r data_note
				} < "$attrs_file"
				render_download "$dir" "$data_src" "$data_title" "$data_note"
				;;
			*'<pre'*'class="code-block"'*)
				require_empty_marker "$source" "$line" pre "Code block"
				write_attrs "$line" "$attrs_file" \
					data-src data-lang data-open data-title data-note data-footer data-header
				{
					IFS= read -r data_src
					IFS= read -r data_lang
					IFS= read -r data_open
					IFS= read -r data_title
					IFS= read -r data_note
					IFS= read -r data_footer
					IFS= read -r data_header
				} < "$attrs_file"
				render_code_block "$dir" "$data_src" "$data_lang" \
					"$data_open" "$data_title" "$data_note" "$data_footer" "$data_header"
				;;
			*'<figure'*'class="image-block"'*)
				require_empty_marker "$source" "$line" figure "Image"
				write_attrs "$line" "$attrs_file" \
					data-src data-alt data-caption data-open data-title data-note data-footer data-header
				{
					IFS= read -r data_src
					IFS= read -r data_alt
					IFS= read -r data_caption
					IFS= read -r data_open
					IFS= read -r data_title
					IFS= read -r data_note
					IFS= read -r data_footer
					IFS= read -r data_header
				} < "$attrs_file"
				render_image_block "$dir" "$data_src" "$data_alt" \
					"$data_caption" "$data_open" "$data_title" "$data_note" "$data_footer" "$data_header"
				;;
			*'<h2'*'</h2>'*|*'<h3'*'</h3>'*)
				case $line in
					*'<h2'*) heading_tag=h2 ;;
					*) heading_tag=h3 ;;
				esac
				IFS="$tab" read -r indexed_tag heading_id heading_title parent_id <&3 ||
					die "Heading index mismatch while rendering $source"
				[ "$indexed_tag" = "$heading_tag" ] ||
					die "Heading index mismatch while rendering $source"
				if [ -z "$(attr id "$line")" ]; then
					printf '%s\n' "$line" |
						sed "s/<$heading_tag/<$heading_tag id=\"$heading_id\"/"
				else
					printf '%s\n' "$line"
				fi
				;;
			*)
				printf '%s\n' "$line"
			;;
		esac
	done < "$source"
	if IFS="$tab" read -r indexed_tag heading_id heading_title parent_id <&3; then
		die "Heading index mismatch while rendering $source"
	fi
	exec 3<&-
}

write_order_input() {
	order=$1
	label=$2
	checked=

	[ "$article_order" = "$order" ] && checked=' checked'

	printf '        <input class="index-order-input" type="radio" '
	printf 'name="index-order" id="index-order-%s" aria-label="Sort by %s"%s>\n' \
		"$order" "$label" "$checked"
}

write_order_label() {
	order=$1
	label=$2
	text=$3

	printf '            <label for="index-order-%s" title="Sort by %s">%s</label>\n' \
		"$order" "$label" "$text"
}

write_nav_list() {
	order=$1
	file=$2

	printf '        <ol class="index-list index-list-%s">\n' "$order"
	while IFS="$tab" read -r slug title modified created; do
		slug_html=$(html_escape_attr "$slug")
		printf '          <li class="index-item" data-article="%s">\n' "$slug_html"
		printf '            <a class="index-article" href="#%s">%s</a>\n' \
			"$slug_html" "$(html_escape_attr "$title")"
		printf '          </li>\n'
	done < "$file"
	printf '        </ol>\n'
}

write_article_section_list() {
	slug=$1
	title=$2
	heading_meta=$3
	slug_html=$(html_escape_attr "$slug")
	title_html=$(html_escape_attr "$title")
	section_open=0
	subsection_open=0

	printf '          <ol class="index-section-list" data-article="%s" aria-label="Sections in %s">\n' \
		"$slug_html" "$title_html"
	while IFS="$tab" read -r heading_tag heading_id heading_title parent_id; do
		heading_id_html=$(html_escape_attr "$heading_id")
		heading_title_html=$(html_escape_attr "$heading_title")
		case $heading_tag in
			h2)
				if [ "$subsection_open" = 1 ]; then
					printf '                </ol>\n'
					subsection_open=0
				fi
				[ "$section_open" = 0 ] || printf '              </li>\n'
				printf '              <li class="index-section-item" data-section="%s">\n' \
					"$heading_id_html"
				printf '                <a href="#%s">%s</a>\n' \
					"$heading_id_html" "$heading_title_html"
				section_title_html=$heading_title_html
				section_open=1
				;;
			h3)
				if [ "$subsection_open" = 0 ]; then
					printf '                <ol class="index-subsection-list" data-section="%s" aria-label="Subsections under %s">\n' \
						"$(html_escape_attr "$parent_id")" "$section_title_html"
					subsection_open=1
				fi
				printf '                  <li><a href="#%s">%s</a></li>\n' \
					"$heading_id_html" "$heading_title_html"
				;;
		esac
	done < "$heading_meta"
	[ "$subsection_open" = 0 ] || printf '                </ol>\n'
	[ "$section_open" = 0 ] || printf '              </li>\n'
	printf '          </ol>\n'
}

write_article_subsection_lists() {
	slug=$1
	heading_meta=$2
	slug_html=$(html_escape_attr "$slug")
	current_section=
	subsection_open=0

	while IFS="$tab" read -r heading_tag heading_id heading_title parent_id; do
		case $heading_tag in
			h2)
				[ "$subsection_open" = 0 ] || printf '          </ol>\n'
				current_section=$heading_id
				section_title=$heading_title
				subsection_open=0
				;;
			h3)
				if [ "$subsection_open" = 0 ]; then
					printf '          <ol class="index-subsection-list" data-article="%s" data-section="%s" aria-label="Subsections under %s">\n' \
						"$slug_html" "$(html_escape_attr "$current_section")" \
						"$(html_escape_attr "$section_title")"
					subsection_open=1
				fi
				printf '            <li><a href="#%s">%s</a></li>\n' \
					"$(html_escape_attr "$heading_id")" \
					"$(html_escape_attr "$heading_title")"
				;;
		esac
	done < "$heading_meta"
	[ "$subsection_open" = 0 ] || printf '          </ol>\n'
}

write_section_lists() {
	file=$1

	printf '        <div class="index-section-lists">\n'
	while IFS="$tab" read -r slug title modified created; do
		heading_meta=$tmp/headings-$slug.tsv
		[ -s "$heading_meta" ] || continue
		write_article_section_list "$slug" "$title" "$heading_meta"
	done < "$file"
	printf '        </div>\n'

	if [ "$subsection_count" -gt 0 ]; then
		printf '        <div class="index-subsection-lists">\n'
		while IFS="$tab" read -r slug title modified created; do
			subsection_meta=$tmp/subsections-$slug.tsv
			[ -s "$subsection_meta" ] || continue
			write_article_subsection_lists "$slug" "$tmp/headings-$slug.tsv"
		done < "$file"
		printf '        </div>\n'
	fi
}

write_index_selected_style() {
	printf '  background: var(--index-selected-bg);\n'
	printf '  border-color: var(--index-selected-border);\n'
	printf '  color: var(--index-selected-text);\n'
}

write_index_subsection_selected_style() {
	printf '  background: var(--index-subsection-selected-bg);\n'
	printf '  border-color: var(--index-subsection-selected-border);\n'
	printf '  color: var(--index-subsection-selected-text);\n'
}

write_portrait_subsection_css() {
	slug=$1
	section_id=$2
	target_id=$3

	printf 'body:has(.article[id="%s"] [id="%s"]:target) {\n' "$slug" "$target_id"
	printf '  --mobile-index-height: 12rem;\n'
	printf '}\n'
	printf 'body:has(.article[id="%s"] [id="%s"]:target) .index-pane {\n' "$slug" "$target_id"
	printf '  grid-template-rows: auto 2.625rem 2.75rem 2.75rem 1.125rem;\n'
	printf '}\n'
	printf 'body:has(.article[id="%s"] [id="%s"]:target) .index-footer {\n' "$slug" "$target_id"
	printf '  grid-row: 5;\n'
	printf '}\n'
	printf 'body:has(.article[id="%s"] [id="%s"]:target) .index-subsection-lists,\n' \
		"$slug" "$target_id"
	printf 'body:has(.article[id="%s"] [id="%s"]:target) .index-subsection-list[data-article="%s"][data-section="%s"] {\n' \
		"$slug" "$target_id" "$slug" "$section_id"
	printf '  display: flex;\n'
	printf '}\n'
}

write_section_nav_css() {
	printf '/* Generated by tools/build.sh. Do not edit by hand. */\n'
	if [ -s "$tmp/section-ids-$first_slug.txt" ]; then
		printf '.shell:not(:has(.article:target)):not(:has(.article :target)) .index-section-lists,\n'
		printf '.shell:not(:has(.article:target)):not(:has(.article :target)) .index-section-list[data-article="%s"] {\n' "$first_slug"
		printf '  display: flex;\n'
		printf '}\n'
	fi
	printf '.shell:not(:has(.article:target)):not(:has(.article :target)) .index-item[data-article="%s"] > .index-article {\n' "$first_slug"
	write_index_selected_style
	printf '}\n'
	while IFS="$tab" read -r slug title modified created; do
		printf 'body:has(.article[id="%s"]:target) .index-item[data-article="%s"] > .index-article,\n' "$slug" "$slug"
		printf 'body:has(.article[id="%s"] :target) .index-item[data-article="%s"] > .index-article {\n' "$slug" "$slug"
		write_index_selected_style
		printf '}\n'
		if [ -s "$tmp/section-ids-$slug.txt" ]; then
			printf 'body:has(.article[id="%s"]:target) .index-section-lists,\n' "$slug"
			printf 'body:has(.article[id="%s"] :target) .index-section-lists,\n' "$slug"
			printf 'body:has(.article[id="%s"]:target) .index-section-list[data-article="%s"],\n' "$slug" "$slug"
			printf 'body:has(.article[id="%s"] :target) .index-section-list[data-article="%s"] {\n' "$slug" "$slug"
			printf '  display: flex;\n'
			printf '}\n'
			while IFS= read -r section_id; do
				printf 'body:has(.article[id="%s"] [id="%s"]:target) .index-section-list[data-article="%s"] .index-section-item[data-section="%s"] > a[href="#%s"] {\n' \
					"$slug" "$section_id" "$slug" "$section_id" "$section_id"
				write_index_selected_style
				printf '}\n'
			done < "$tmp/section-ids-$slug.txt"

			while IFS= read -r section_id; do
				printf 'body:has(.article[id="%s"] [id="%s"]:target) .index-section-list[data-article="%s"] .index-section-item[data-section="%s"] > .index-subsection-list {\n' \
					"$slug" "$section_id" "$slug" "$section_id"
				printf '  display: flex;\n'
				printf '}\n'
			done < "$tmp/subsection-parents-$slug.txt"

			while IFS="$tab" read -r section_id subsection_id; do
				printf 'body:has(.article[id="%s"] [id="%s"]:target) .index-section-list[data-article="%s"] .index-section-item[data-section="%s"] > a[href="#%s"] {\n' \
					"$slug" "$subsection_id" "$slug" "$section_id" "$section_id"
				write_index_selected_style
				printf '}\n'
				printf 'body:has(.article[id="%s"] [id="%s"]:target) .index-subsection-list[data-section="%s"] a[href="#%s"] {\n' \
					"$slug" "$subsection_id" "$section_id" "$subsection_id"
				write_index_subsection_selected_style
				printf '}\n'
				printf 'body:has(.article[id="%s"] [id="%s"]:target) .index-section-list[data-article="%s"] .index-section-item[data-section="%s"] > .index-subsection-list {\n' \
					"$slug" "$subsection_id" "$slug" "$section_id"
				printf '  display: flex;\n'
				printf '}\n'
			done < "$tmp/subsections-$slug.tsv"
		fi
	done < "$article_meta"

	if [ "$subsection_count" -gt 0 ]; then
		printf '@media (max-width: 760px) and (orientation: portrait) {\n'
		while IFS="$tab" read -r slug title modified created; do
			while IFS= read -r section_id; do
				write_portrait_subsection_css "$slug" "$section_id" "$section_id"
			done < "$tmp/subsection-parents-$slug.txt"
			while IFS="$tab" read -r section_id subsection_id; do
				write_portrait_subsection_css "$slug" "$section_id" "$subsection_id"
			done < "$tmp/subsections-$slug.tsv"
		done < "$article_meta"
		printf '}\n'
	fi
}

write_license_popover() {
	cat <<EOF
        <div class="license-popover" id="license-popover" popover>
          <div class="license-head">
            <h2>$license_title_html</h2>
            <button class="license-close" type="button" popovertarget="license-popover" popovertargetaction="hide" aria-label="Close license">X</button>
          </div>
          <pre>$license_body_html</pre>
        </div>
EOF
}

write_contact_popover() {
	cat <<EOF
        <div class="contact-popover" id="contact-popover" popover>
          <div class="contact-head">
            <h2>Contact</h2>
            <button class="contact-close" type="button" popovertarget="contact-popover" popovertargetaction="hide" aria-label="Close contact form">X</button>
          </div>
          <form class="contact-form" action="./#contact-confirmation" method="get" accept-charset="UTF-8">
            <input type="hidden" name="$contact_field_name_html" value="1">
            <p class="contact-note">Messages are encrypted in transit over HTTPS, but remain in the URL and server log. Do not include confidential information.</p>
            <label class="contact-field">
              <span>Reply address (optional)</span>
              <input type="email" name="reply" maxlength="254" autocomplete="email">
            </label>
            <label class="contact-field">
              <span>Message</span>
              <textarea name="message" maxlength="500" required></textarea>
            </label>
            <button class="contact-submit" type="submit">Send</button>
          </form>
        </div>
EOF
}

write_contact_confirmation() {
	cat <<'EOF'
    <section class="contact-confirmation" id="contact-confirmation" aria-label="Contact confirmation">
      <div class="contact-head">
        <h2>Message sent</h2>
        <a class="contact-close" href="./" aria-label="Close confirmation">X</a>
      </div>
      <p>Thank you. Your message has been sent.</p>
    </section>
EOF
}

write_page_start() {
	cat <<EOF
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>$site_title_html</title>
    <link rel="icon" href="$favicon_href_html">
    <link rel="stylesheet" href="styles.css?v=$asset_version_html">
    <link rel="stylesheet" href="nav-sections.css?v=$asset_version_html">
  </head>
  <body>
    <header class="site-header">
      <a class="brand" href="#$first_slug_html" aria-label="$site_title_html home">
        <span class="brand-mark" aria-hidden="true" style="--brand-mark-color: $site_mark_color_html">$site_mark_html</span>
        $site_title_html
      </a>
EOF
	write_theme_menu
	cat <<EOF
    </header>

    <main class="shell">
      <nav class="index-pane" aria-label="Article index">
EOF
	write_order_input title title
	write_order_input created creation
	write_order_input modified modification
	cat <<EOF
        <div class="index-head">
          <h2>Index</h2>
          <div class="index-order" aria-label="Index order">
EOF
	write_order_label title title A
	write_order_label created creation C
	write_order_label modified modification M
	cat <<EOF
          </div>
        </div>
EOF
}

articles_file=$tmp/articles.html
article_meta=$tmp/articles.tsv
anchor_ids=$tmp/anchors.txt
order_title_file=$tmp/order-title.tsv
order_created_file=$tmp/order-created.tsv
order_modified_file=$tmp/order-modified.tsv
: > "$articles_file"
: > "$article_meta"
: > "$anchor_ids"
count=0
subsection_count=0

[ -f "$theme_menu" ] || die "Missing theme menu: $theme_menu"
[ -f "$styles_file" ] || die "Missing stylesheet: $styles_file"
[ -f "$license_file" ] || die "Missing license file: $license_file"

reserve_anchor index-order-title "generated index controls"
reserve_anchor index-order-created "generated index controls"
reserve_anchor index-order-modified "generated index controls"
reserve_anchor contact-popover "generated contact popover"
reserve_anchor contact-confirmation "generated contact confirmation"
reserve_anchor license-popover "generated license popover"
theme_ids=$tmp/theme-ids.txt
sed -n 's/.*[[:space:]]id="\([^"]*\)".*/\1/p' "$theme_menu" > "$theme_ids"
while IFS= read -r theme_anchor; do
	reserve_anchor "$theme_anchor" "$theme_menu"
done < "$theme_ids"

case $highlighter in
	pygments|source-highlight|none) ;;
	*) die "HIGHLIGHTER must be pygments, source-highlight, or none." ;;
esac
case $generator_version in
	''|*[!0-9.]*|.*|*.|*..*)
		die "GENERATOR_VERSION must contain dot-separated digits only."
		;;
esac
generator_id=$(sanitize_identifier "$generator_name")
[ -n "$generator_id" ] ||
	die "GENERATOR_NAME must contain an ASCII letter, digit, dot, underscore, or hyphen."
contact_field_name=${generator_id}_contact
case $code_footer_lines in
	''|*[!0-9]*) die "CODE_FOOTER_LINES must be a non-negative integer." ;;
esac
case $article_order in
	title|'') article_order=title ;;
	created|creation|ctime) article_order=created ;;
	modified|modification|mtime) article_order=modified ;;
	*) die "ARTICLE_ORDER must be title, created, or modified." ;;
esac

theme_id=$(normalize_theme "$default_theme")
safe_name "$theme_id" || die "Unsafe DEFAULT_THEME: $default_theme"
grep -F -q -e "id=\"$theme_id\"" "$theme_menu" ||
	die "DEFAULT_THEME does not exist in theme menu: $default_theme"
safe_color "$site_mark_color" || die "SITE_MARK_COLOR must be #rgb or #rrggbb."

for source in "$articles_dir"/*/article.html; do
	[ -f "$source" ] || continue
	dir=${source%/*}
	slug=${dir##*/}

	[ ! -L "$dir" ] || die "Refusing symlink article directory: $dir"
	[ ! -L "$source" ] || die "Refusing symlink article: $source"
	safe_name "$slug" || die "Unsafe article directory name: $slug"

	article_line=$(sed -n '/<article /{p;q;}' "$source")
	write_attrs "$article_line" "$attrs_file" \
		class data-title id data-created data-modified
	{
		IFS= read -r article_class
		IFS= read -r title
		IFS= read -r article_id
		IFS= read -r created
		IFS= read -r modified
	} < "$attrs_file"

	case $article_line in
		*'>'*) ;;
		*) die "Article opening tag must be contained on one line: $source" ;;
	esac
	case " $article_class " in
		*' article '*) ;;
		*) die "$source article element must include class=\"article\"." ;;
	esac
	[ -n "$title" ] || die "Missing article data-title in $source"
	[ -n "$created" ] || created=$build_day
	[ -n "$modified" ] || modified=$created
	safe_date "$created" || die "Article data-created must begin YYYY-MM-DD: $source"
	safe_date "$modified" || die "Article data-modified must begin YYYY-MM-DD: $source"
	[ "$article_id" = "$slug" ] || die "$source must contain id=\"$slug\" on its article element."
	reserve_anchor "$slug" "$source"
	case $title in
		*"$tab"*) die "Article title may not contain tabs: $source" ;;
	esac

	count=$((count + 1))

	printf '%s\t%s\t%s\t%s\n' \
		"$slug" "$title" "$modified" "$created" >> "$article_meta"
	read_sections "$source" "$slug"
done

[ "$count" -gt 0 ] || die "No articles found under $articles_dir/*/article.html."

sort_articles title > "$order_title_file"
sort_articles created > "$order_created_file"
sort_articles modified > "$order_modified_file"

case $article_order in
	title) order_file=$order_title_file ;;
	created) order_file=$order_created_file ;;
	modified) order_file=$order_modified_file ;;
esac

while IFS="$tab" read -r slug title modified created; do
	[ -n "${first_slug:-}" ] || first_slug=$slug
	render_article "$articles_dir/$slug" "$articles_dir/$slug/article.html" >> "$articles_file"
	printf '\n' >> "$articles_file"
done < "$order_file"

site_title_html=$(html_escape_attr "$site_title")
site_mark_html=$(html_escape_attr "$site_mark")
site_mark_color_html=$(html_escape_attr "$site_mark_color")
favicon_href_html=$(html_escape_attr "$(favicon_href "$site_mark")")
first_slug_html=$(html_escape_attr "$first_slug")
generator_label_html=$(html_escape_attr "$generator_name $generator_version")
contact_field_name_html=$(html_escape_attr "$contact_field_name")
build_date_html=$(html_escape_attr "$build_date")
license_html=$tmp/license.html
license_escape > "$license_html"
license_title_html=$(sed -n '/[^[:space:]]/{p;q;}' "$license_html")
[ -n "$license_title_html" ] || die "License file has no title: $license_file"
license_body_html=$(awk '
	seen { print }
	!seen && /[^[:space:]]/ { seen = 1 }
' "$license_html")
nav_tmp=$(mktemp "$root/.nav-sections.css.XXXXXXXXXX")
write_section_nav_css > "$nav_tmp"
asset_version=$(
	{
		cksum < "$styles_file"
		cksum < "$nav_tmp"
	} | cksum | awk '{ print $1 }'
)
asset_version_html=$(html_escape_attr "$asset_version")
output_tmp=$(mktemp "$root/.index.html.XXXXXXXXXX")

{
	write_page_start
	write_nav_list title "$order_title_file"
	write_nav_list created "$order_created_file"
	write_nav_list modified "$order_modified_file"
	write_section_lists "$order_file"
	cat <<EOF
        <footer class="index-footer">
          <p>$generator_label_html</p>
          <p>zero-JS, cookie-free static site</p>
          <p>Built $build_date_html</p>
          <p class="index-footer-actions">
            <button class="contact-button" type="button" popovertarget="contact-popover">Contact</button>
            <button class="license-button" type="button" popovertarget="license-popover">$license_title_html</button>
          </p>
        </footer>
EOF
	write_contact_popover
	write_license_popover
	cat <<EOF
      </nav>

      <section class="article-pane" aria-label="Articles">
EOF
	cat "$articles_file"
	cat <<EOF
      </section>
    </main>
EOF
	write_contact_confirmation
	cat <<EOF
  </body>
</html>
EOF
} > "$output_tmp"

chmod 0644 "$nav_tmp" "$output_tmp"
mv "$nav_tmp" "$root/nav-sections.css"
nav_tmp=
mv "$output_tmp" "$root/index.html"
output_tmp=

printf 'Built %s articles into index.html.\n' "$count"
printf 'Default theme: %s. Article order: %s.\n' "$theme_id" "$article_order"
case $highlighter in
	pygments)
		if [ -n "$pygmentize" ]; then
			printf 'Pygments available: %s highlighted, %s escaped raw.\n' \
				"$highlighted_blocks" "$raw_code_blocks"
		else
			printf 'pygmentize not found; %s code blocks use escaped raw source.\n' \
				"$raw_code_blocks"
		fi
		;;
	source-highlight)
		if [ -z "$source_highlight" ]; then
			printf 'source-highlight not found; %s code blocks use escaped raw source.\n' \
				"$raw_code_blocks"
		elif [ ! -f "$outlang" ]; then
			printf 'Source-highlight output definition missing; %s code blocks use escaped raw source.\n' \
				"$raw_code_blocks"
		else
			printf 'Source-highlight available: %s highlighted, %s escaped raw.\n' \
				"$highlighted_blocks" "$raw_code_blocks"
		fi
		;;
	none)
		printf 'Syntax highlighting disabled; %s code blocks use escaped raw source.\n' \
			"$raw_code_blocks"
		;;
esac
styles.cssLayout, themes, and syntax coloursraw
:root {
  color-scheme: dark;

  --base03: #002b36;
  --base02: #073642;
  --base01: #586e75;
  --base00: #657b83;
  --base0: #839496;
  --base1: #93a1a1;
  --base2: #eee8d5;
  --base3: #fdf6e3;
  --yellow: #b58900;
  --orange: #cb4b16;
  --red: #dc322f;
  --magenta: #d33682;
  --violet: #6c71c4;
  --blue: #268bd2;
  --cyan: #2aa198;
  --green: #859900;

  --page-bg: var(--base03);
  --panel-bg: var(--base02);
  --text: var(--base0);
  --strong: var(--base3);
  --muted: var(--base1);
  --line: var(--base01);
  --code-bg: var(--base03);
  --code-text: var(--base0);
  --code-border: var(--base02);
  --code-title-bg: var(--base02);
  --code-title-text: var(--base1);
  --download-hover-border: var(--line);
  --header-height: 3rem;
  --image-min-height: 12rem;
  --image-max-height: 32rem;
  --font-ui: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", "Liberation Sans", "DejaVu Sans", Arial, sans-serif;
  --font-mono: ui-monospace, "SFMono-Regular", "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "DejaVu Sans Mono", "Noto Sans Mono", "Courier New", monospace;

  font-family: var(--font-ui);
}

body:has(#theme-selenized-dark:checked) {
  color-scheme: dark;

  --base03: #103c48;
  --base02: #184956;
  --base01: #72898f;
  --base00: #adbcbc;
  --base0: #adbcbc;
  --base1: #cad8d9;
  --base2: #2d5b69;
  --base3: #cad8d9;
  --yellow: #dbb32d;
  --orange: #ed8649;
  --red: #fa5750;
  --magenta: #f275be;
  --violet: #af88eb;
  --blue: #4695f7;
  --cyan: #41c7b9;
  --green: #75b938;
}

body:has(#theme-selenized-black:checked) {
  color-scheme: dark;

  --base03: #181818;
  --base02: #252525;
  --base01: #777777;
  --base00: #b9b9b9;
  --base0: #b9b9b9;
  --base1: #dedede;
  --base2: #3b3b3b;
  --base3: #dedede;
  --yellow: #dbb32d;
  --orange: #e67f43;
  --red: #ed4a46;
  --magenta: #eb6eb7;
  --violet: #a580e2;
  --blue: #368aeb;
  --cyan: #3fc5b7;
  --green: #70b433;
}

body:has(#theme-selenized-light:checked) {
  --base03: #3a4d53;
  --base02: #53676d;
  --base01: #909995;
  --base00: #53676d;
  --base0: #909995;
  --base1: #d5cdb6;
  --base2: #ece3cc;
  --base3: #fbf3db;
  --yellow: #ad8900;
  --orange: #c25d1e;
  --red: #d2212d;
  --magenta: #ca4898;
  --violet: #8762c6;
  --blue: #0072d4;
  --cyan: #009c8f;
  --green: #489100;
}

body:has(#theme-selenized-white:checked) {
  --base03: #282828;
  --base02: #474747;
  --base01: #878787;
  --base00: #474747;
  --base0: #878787;
  --base1: #cdcdcd;
  --base2: #ebebeb;
  --base3: #ffffff;
  --yellow: #c49700;
  --orange: #d04a00;
  --red: #d6000c;
  --magenta: #dd0f9d;
  --violet: #7f51d6;
  --blue: #0064e4;
  --cyan: #00ad9c;
  --green: #1d9700;
}

body:has(#theme-oksolar-dark:checked),
body:has(#theme-oksolar-light:checked) {
  --base03: #002d38;
  --base02: #093946;
  --base01: #5b7279;
  --base00: #657377;
  --base0: #98a8a8;
  --base1: #8faaab;
  --base2: #f1e9d2;
  --base3: #fbf7ef;
  --yellow: #ac8300;
  --orange: #d56500;
  --red: #f23749;
  --magenta: #dd459d;
  --violet: #7d80d1;
  --blue: #2b90d8;
  --cyan: #259d94;
  --green: #819500;
}

body:has(#theme-gruvbox-dark:checked) {
  color-scheme: dark;

  --base03: #282828;
  --base02: #3c3836;
  --base01: #665c54;
  --base00: #928374;
  --base0: #ebdbb2;
  --base1: #d5c4a1;
  --base2: #504945;
  --base3: #fbf1c7;
  --yellow: #fabd2f;
  --orange: #fe8019;
  --red: #fb4934;
  --magenta: #d3869b;
  --violet: #d3869b;
  --blue: #83a598;
  --cyan: #8ec07c;
  --green: #b8bb26;
}

body:has(#theme-gruvbox-light:checked) {
  --base03: #282828;
  --base02: #3c3836;
  --base01: #928374;
  --base00: #3c3836;
  --base0: #928374;
  --base1: #d5c4a1;
  --base2: #ebdbb2;
  --base3: #fbf1c7;
  --yellow: #b57614;
  --orange: #af3a03;
  --red: #9d0006;
  --magenta: #8f3f71;
  --violet: #8f3f71;
  --blue: #076678;
  --cyan: #427b58;
  --green: #79740e;
}

body:has(#theme-tomorrow:checked) {
  --base03: #1d1f21;
  --base02: #373b41;
  --base01: #8e908c;
  --base00: #4d4d4c;
  --base0: #8e908c;
  --base1: #c5c8c6;
  --base2: #efefef;
  --base3: #ffffff;
  --yellow: #eab700;
  --orange: #f5871f;
  --red: #c82829;
  --magenta: #8959a8;
  --violet: #8959a8;
  --blue: #4271ae;
  --cyan: #3e999f;
  --green: #718c00;
}

body:has(#theme-tomorrow-night:checked) {
  --base03: #1d1f21;
  --base02: #282a2e;
  --base01: #969896;
  --base00: #b4b7b4;
  --base0: #c5c8c6;
  --base1: #e0e0e0;
  --base2: #373b41;
  --base3: #ffffff;
  --yellow: #f0c674;
  --orange: #de935f;
  --red: #cc6666;
  --magenta: #b294bb;
  --violet: #b294bb;
  --blue: #81a2be;
  --cyan: #8abeb7;
  --green: #b5bd68;
}

body:has(#theme-tomorrow-eighties:checked) {
  --base03: #2d2d2d;
  --base02: #393939;
  --base01: #999999;
  --base00: #cccccc;
  --base0: #cccccc;
  --base1: #e0e0e0;
  --base2: #515151;
  --base3: #ffffff;
  --yellow: #ffcc66;
  --orange: #f99157;
  --red: #f2777a;
  --magenta: #cc99cc;
  --violet: #cc99cc;
  --blue: #6699cc;
  --cyan: #66cccc;
  --green: #99cc99;
}

body:has(#theme-tomorrow-blue:checked) {
  --base03: #002451;
  --base02: #00346e;
  --base01: #7285b7;
  --base00: #ffffff;
  --base0: #ffffff;
  --base1: #ffffff;
  --base2: #003f8e;
  --base3: #ffffff;
  --yellow: #ffc58f;
  --orange: #ff9da4;
  --red: #ff9da4;
  --magenta: #ebbbff;
  --violet: #ebbbff;
  --blue: #bbdaff;
  --cyan: #99ffff;
  --green: #d1f1a9;
}

body:has(#theme-tomorrow-bright:checked) {
  --base03: #000000;
  --base02: #2a2a2a;
  --base01: #969896;
  --base00: #eaeaea;
  --base0: #eaeaea;
  --base1: #ffffff;
  --base2: #424242;
  --base3: #ffffff;
  --yellow: #e7c547;
  --orange: #e78c45;
  --red: #d54e53;
  --magenta: #c397d8;
  --violet: #c397d8;
  --blue: #7aa6da;
  --cyan: #70c0b1;
  --green: #b9ca4a;
}

body:has(#theme-zenburn:checked) {
  --base03: #3f3f3f;
  --base02: #4f4f4f;
  --base01: #7f9f7f;
  --base00: #dcdccc;
  --base0: #dcdccc;
  --base1: #ffffef;
  --base2: #5f5f5f;
  --base3: #ffffef;
  --yellow: #f0dfaf;
  --orange: #dfaf8f;
  --red: #cc9393;
  --magenta: #dc8cc3;
  --violet: #dc8cc3;
  --blue: #8cd0d3;
  --cyan: #93e0e3;
  --green: #7f9f7f;
}

body:has(#theme-nord:checked) {
  --base03: #2e3440;
  --base02: #3b4252;
  --base01: #4c566a;
  --base00: #d8dee9;
  --base0: #d8dee9;
  --base1: #e5e9f0;
  --base2: #434c5e;
  --base3: #eceff4;
  --yellow: #ebcb8b;
  --orange: #d08770;
  --red: #bf616a;
  --magenta: #b48ead;
  --violet: #b48ead;
  --blue: #81a1c1;
  --cyan: #88c0d0;
  --green: #a3be8c;
}

body:has(#theme-everforest-dark:checked) {
  --base03: #2d353b;
  --base02: #343f44;
  --base01: #7a8478;
  --base00: #9da9a0;
  --base0: #d3c6aa;
  --base1: #d3c6aa;
  --base2: #475258;
  --base3: #d3c6aa;
  --yellow: #dbbc7f;
  --orange: #e69875;
  --red: #e67e80;
  --magenta: #d699b6;
  --violet: #d699b6;
  --blue: #7fbbb3;
  --cyan: #83c092;
  --green: #a7c080;
}

body:has(#theme-everforest-light:checked) {
  --base03: #5c6a72;
  --base02: #708089;
  --base01: #829181;
  --base00: #5c6a72;
  --base0: #939f91;
  --base1: #bdc3af;
  --base2: #f4f0d9;
  --base3: #fdf6e3;
  --yellow: #dfa000;
  --orange: #f57d26;
  --red: #f85552;
  --magenta: #df69ba;
  --violet: #df69ba;
  --blue: #3a94c5;
  --cyan: #35a77c;
  --green: #8da101;
}

body:has(#theme-ayu-dark:checked) {
  --base03: #0a0e14;
  --base02: #0d1017;
  --base01: #626a73;
  --base00: #b3b1ad;
  --base0: #b3b1ad;
  --base1: #e6e1cf;
  --base2: #11151c;
  --base3: #e6e1cf;
  --yellow: #e6b450;
  --orange: #ff8f40;
  --red: #ff3333;
  --magenta: #d2a6ff;
  --violet: #d2a6ff;
  --blue: #59c2ff;
  --cyan: #95e6cb;
  --green: #aad94c;
}

body:has(#theme-ayu-mirage:checked) {
  --base03: #1f2430;
  --base02: #242936;
  --base01: #5c6773;
  --base00: #cbccc6;
  --base0: #cbccc6;
  --base1: #f3f4f5;
  --base2: #2a3040;
  --base3: #f3f4f5;
  --yellow: #ffd580;
  --orange: #ffa759;
  --red: #ff3333;
  --magenta: #d4bfff;
  --violet: #d4bfff;
  --blue: #73d0ff;
  --cyan: #95e6cb;
  --green: #bae67e;
}

body:has(#theme-ayu-light:checked) {
  --base03: #5c6773;
  --base02: #8a9199;
  --base01: #abb0b6;
  --base00: #5c6773;
  --base0: #8a9199;
  --base1: #f0f0f0;
  --base2: #f3f4f5;
  --base3: #fcfcfc;
  --yellow: #a37acc;
  --orange: #fa8d3e;
  --red: #f51818;
  --magenta: #a37acc;
  --violet: #a37acc;
  --blue: #399ee6;
  --cyan: #4cbf99;
  --green: #86b300;
}

body:has(#theme-catppuccin-latte:checked) {
  --base03: #4c4f69;
  --base02: #5c5f77;
  --base01: #8c8fa1;
  --base00: #4c4f69;
  --base0: #6c6f85;
  --base1: #bcc0cc;
  --base2: #e6e9ef;
  --base3: #eff1f5;
  --yellow: #df8e1d;
  --orange: #fe640b;
  --red: #d20f39;
  --magenta: #ea76cb;
  --violet: #8839ef;
  --blue: #1e66f5;
  --cyan: #179299;
  --green: #40a02b;
}

body:has(#theme-catppuccin-frappe:checked) {
  --base03: #303446;
  --base02: #414559;
  --base01: #838ba7;
  --base00: #a5adce;
  --base0: #c6d0f5;
  --base1: #b5bfe2;
  --base2: #292c3c;
  --base3: #c6d0f5;
  --yellow: #e5c890;
  --orange: #ef9f76;
  --red: #e78284;
  --magenta: #f4b8e4;
  --violet: #ca9ee6;
  --blue: #8caaee;
  --cyan: #81c8be;
  --green: #a6d189;
}

body:has(#theme-catppuccin-macchiato:checked) {
  --base03: #24273a;
  --base02: #363a4f;
  --base01: #8087a2;
  --base00: #a5adcb;
  --base0: #cad3f5;
  --base1: #b8c0e0;
  --base2: #1e2030;
  --base3: #cad3f5;
  --yellow: #eed49f;
  --orange: #f5a97f;
  --red: #ed8796;
  --magenta: #f5bde6;
  --violet: #c6a0f6;
  --blue: #8aadf4;
  --cyan: #8bd5ca;
  --green: #a6da95;
}

body:has(#theme-catppuccin-mocha:checked) {
  --base03: #1e1e2e;
  --base02: #313244;
  --base01: #7f849c;
  --base00: #a6adc8;
  --base0: #cdd6f4;
  --base1: #bac2de;
  --base2: #181825;
  --base3: #cdd6f4;
  --yellow: #f9e2af;
  --orange: #fab387;
  --red: #f38ba8;
  --magenta: #f5c2e7;
  --violet: #cba6f7;
  --blue: #89b4fa;
  --cyan: #94e2d5;
  --green: #a6e3a1;
}

body:has(#theme-rose-pine:checked) {
  --base03: #191724;
  --base02: #1f1d2e;
  --base01: #6e6a86;
  --base00: #908caa;
  --base0: #e0def4;
  --base1: #908caa;
  --base2: #26233a;
  --base3: #e0def4;
  --yellow: #f6c177;
  --orange: #ebbcba;
  --red: #eb6f92;
  --magenta: #c4a7e7;
  --violet: #c4a7e7;
  --blue: #31748f;
  --cyan: #9ccfd8;
  --green: #9ccfd8;
}

body:has(#theme-rose-pine-moon:checked) {
  --base03: #232136;
  --base02: #2a273f;
  --base01: #6e6a86;
  --base00: #908caa;
  --base0: #e0def4;
  --base1: #908caa;
  --base2: #393552;
  --base3: #e0def4;
  --yellow: #f6c177;
  --orange: #ea9a97;
  --red: #eb6f92;
  --magenta: #c4a7e7;
  --violet: #c4a7e7;
  --blue: #3e8fb0;
  --cyan: #9ccfd8;
  --green: #9ccfd8;
}

body:has(#theme-rose-pine-dawn:checked) {
  --base03: #464261;
  --base02: #797593;
  --base01: #9893a5;
  --base00: #464261;
  --base0: #797593;
  --base1: #f2e9e1;
  --base2: #fffaf3;
  --base3: #faf4ed;
  --yellow: #ea9d34;
  --orange: #d7827e;
  --red: #b4637a;
  --magenta: #907aa9;
  --violet: #907aa9;
  --blue: #286983;
  --cyan: #56949f;
  --green: #56949f;
}

* {
  box-sizing: border-box;
}

html {
  margin: 0;
  min-block-size: 100%;
}

body {
  margin: 0;
  min-block-size: 100%;
  --page-bg: var(--base03);
  --panel-bg: var(--base02);
  --text: var(--base0);
  --strong: var(--base3);
  --muted: var(--base1);
  --line: var(--base01);
  --code-bg: var(--base03);
  --code-text: var(--base0);
  --code-border: var(--base02);
  --code-title-bg: var(--base02);
  --code-title-text: var(--base1);
  --download-hover-border: var(--line);
  --index-selected-bg: var(--page-bg);
  --index-selected-border: var(--blue);
  --index-selected-text: var(--strong);
  --index-subsection-selected-bg: color-mix(in srgb, var(--index-selected-bg) 55%, var(--panel-bg));
  --index-subsection-selected-border: color-mix(in srgb, var(--index-selected-border) 55%, var(--line));
  --index-subsection-selected-text: var(--index-selected-text);
  --syntax-plain: color-mix(in srgb, var(--code-text) 70%, white);
  --syntax-muted: color-mix(in srgb, var(--muted) 70%, white);
  --syntax-green: color-mix(in srgb, var(--green) 70%, white);
  --syntax-cyan: color-mix(in srgb, var(--cyan) 70%, white);
  --syntax-blue: color-mix(in srgb, var(--blue) 70%, white);
  --syntax-orange: color-mix(in srgb, var(--orange) 70%, white);
  --syntax-yellow: color-mix(in srgb, var(--yellow) 70%, white);
  --syntax-magenta: color-mix(in srgb, var(--magenta) 70%, white);
  --syntax-violet: color-mix(in srgb, var(--violet) 70%, white);
  --syntax-red: color-mix(in srgb, var(--red) 70%, white);

  background: var(--page-bg);
  color: var(--text);
  font-size: 1rem;
  line-height: 1.65;
}

body:has(#theme-solarized-light:checked),
body:has(#theme-selenized-light:checked),
body:has(#theme-selenized-white:checked),
body:has(#theme-oksolar-light:checked),
body:has(#theme-gruvbox-light:checked),
body:has(#theme-tomorrow:checked),
body:has(#theme-everforest-light:checked),
body:has(#theme-ayu-light:checked),
body:has(#theme-catppuccin-latte:checked),
body:has(#theme-rose-pine-dawn:checked) {
  color-scheme: light;

  --page-bg: var(--base3);
  --panel-bg: var(--base2);
  --text: var(--base00);
  --strong: var(--base03);
  --muted: var(--base01);
  --line: var(--base1);
  --code-bg: var(--base3);
  --code-text: var(--base00);
  --code-border: var(--base1);
  --code-title-bg: var(--base2);
  --code-title-text: var(--base01);
  --download-hover-border: var(--muted);
  --syntax-plain: color-mix(in srgb, var(--code-text) 60%, black);
  --syntax-muted: color-mix(in srgb, var(--muted) 60%, black);
  --syntax-green: color-mix(in srgb, var(--green) 60%, black);
  --syntax-cyan: color-mix(in srgb, var(--cyan) 60%, black);
  --syntax-blue: color-mix(in srgb, var(--blue) 60%, black);
  --syntax-orange: color-mix(in srgb, var(--orange) 60%, black);
  --syntax-yellow: color-mix(in srgb, var(--yellow) 60%, black);
  --syntax-magenta: color-mix(in srgb, var(--magenta) 60%, black);
  --syntax-violet: color-mix(in srgb, var(--violet) 60%, black);
  --syntax-red: color-mix(in srgb, var(--red) 60%, black);
}

a {
  color: inherit;
}

.site-header {
  position: sticky;
  top: 0;
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  block-size: var(--header-height);
  border-block-end: 1px solid var(--line);
  background: var(--panel-bg);
}

.brand {
  display: inline-flex;
  align-items: center;
  gap: 0.6rem;
  padding-inline: 1rem;
  color: var(--strong);
  font-weight: 700;
  text-decoration: none;
}

.brand:focus-visible,
.index-pane a:focus-visible,
.article-download-hit:focus-visible,
.theme-button:focus-visible,
.theme-close:focus-visible,
.theme-list input:focus-visible + label,
.contact-button:focus-visible,
.contact-close:focus-visible,
.contact-submit:focus-visible,
.license-button:focus-visible,
.license-close:focus-visible,
.index-pane > #index-order-title:focus-visible ~ .index-head label[for="index-order-title"],
.index-pane > #index-order-created:focus-visible ~ .index-head label[for="index-order-created"],
.index-pane > #index-order-modified:focus-visible ~ .index-head label[for="index-order-modified"] {
  outline: 2px solid var(--strong);
  outline-offset: 2px;
}

.contact-form input:focus-visible,
.contact-form textarea:focus-visible {
  outline: 2px solid var(--line);
  outline-offset: -2px;
}

.brand-mark {
  color: var(--brand-mark-color, var(--cyan));
  font-size: 1.7rem;
  font-weight: 400;
  line-height: 1;
  text-shadow: 0 0 0.01em var(--yellow);
}

.theme-button {
  margin-inline-start: auto;
  cursor: pointer;
  border: 1px solid var(--line);
  background: transparent;
  padding: 0.25rem 0.55rem;
  color: var(--muted);
  font: inherit;
  font-size: 0.8rem;
  font-weight: 700;
  line-height: 1.3;
  margin-inline-end: 1rem;
}

.theme-button:hover {
  border-color: var(--strong);
  color: var(--strong);
}

.theme-menu {
  inset: calc(var(--header-height) + 0.5rem) 1rem auto auto;
  display: none;
  overflow: auto;
  inline-size: max-content;
  max-inline-size: calc(100vw - 2rem);
  max-block-size: calc(100vh - var(--header-height) - 1rem);
  max-block-size: calc(100svh - var(--header-height) - 1rem);
  max-block-size: calc(100dvh - var(--header-height) - 1rem);
  margin: 0;
  border: 1px solid var(--line);
  background: var(--panel-bg);
  color: var(--text);
  padding: 0;
  overscroll-behavior: contain;
  scrollbar-color: var(--line) var(--panel-bg);
}

.theme-menu:popover-open {
  display: block;
}

.contact-popover,
.contact-confirmation,
.license-popover {
  inset: auto auto 1rem 1rem;
  box-sizing: border-box;
  display: none;
  overflow-x: hidden;
  overflow-y: auto;
  max-block-size: min(27rem, calc(100vh - 2rem));
  max-block-size: min(27rem, calc(100svh - 2rem));
  max-block-size: min(27rem, calc(100dvh - 2rem));
  margin: 0;
  border: 1px solid var(--line);
  background: var(--panel-bg);
  color: var(--text);
  color-scheme: inherit;
  padding: 0;
  overscroll-behavior: contain;
  scrollbar-color: var(--line) var(--panel-bg);
}

.contact-popover,
.contact-confirmation {
  inline-size: min(28rem, calc(100vw - 2rem));
}

.contact-confirmation {
  position: fixed;
  z-index: 3;
}

.license-popover {
  inline-size: min(39rem, calc(100vw - 2rem));
}

.contact-popover:popover-open,
.license-popover:popover-open {
  display: block;
}

.contact-confirmation:target {
  display: block;
}

.theme-head,
.contact-head,
.license-head {
  position: sticky;
  top: 0;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.6rem;
  border-block-end: 1px solid var(--line);
  background: var(--code-title-bg);
  color: var(--code-title-text);
  padding: 0.45rem 0.7rem;
  font: 0.85rem/1.3 var(--font-mono);
}

.theme-head h2,
.contact-head h2,
.license-head h2 {
  min-inline-size: 0;
  margin: 0;
  color: inherit;
  font: inherit;
  overflow-wrap: anywhere;
}

.theme-close,
.contact-close,
.license-close {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: none;
  min-inline-size: 1.5rem;
  min-block-size: 1.5rem;
  cursor: pointer;
  border: 0;
  background: transparent;
  padding: 0.1rem;
  color: var(--blue);
  font: inherit;
  font-weight: 700;
  line-height: 1;
  text-decoration: none;
}

.theme-close:hover,
.contact-close:hover,
.license-close:hover {
  color: var(--strong);
}

.contact-form {
  display: grid;
  gap: 0.7rem;
  padding: 0.75rem;
  font: 0.75rem/1.4 var(--font-mono);
}

.contact-note,
.contact-confirmation > p {
  margin: 0;
  color: var(--muted);
}

.contact-field {
  display: grid;
  gap: 0.25rem;
  color: var(--strong);
  font-weight: 700;
}

.contact-field input,
.contact-field textarea {
  box-sizing: border-box;
  inline-size: 100%;
  border: 1px solid var(--line);
  border-radius: 0;
  background: var(--code-bg);
  padding: 0.45rem 0.5rem;
  color: var(--text);
  font: inherit;
  font-weight: 400;
}

.contact-field textarea {
  min-block-size: 7rem;
  resize: vertical;
}

.contact-submit {
  justify-self: start;
  cursor: pointer;
  border: 1px solid var(--line);
  border-radius: 0;
  background: var(--code-title-bg);
  padding: 0.35rem 0.7rem;
  color: var(--code-title-text);
  font: inherit;
  font-weight: 700;
}

.contact-submit:hover {
  border-color: var(--strong);
}

.contact-confirmation > p {
  padding: 0.75rem;
  font: 0.75rem/1.4 var(--font-mono);
}

.license-popover pre {
  margin: 0.7rem;
  color: var(--muted);
  font: 0.68rem/1.42 var(--font-mono);
  overflow-wrap: anywhere;
  tab-size: 4;
  white-space: pre-wrap;
}

.contact-popover::backdrop,
.license-popover::backdrop {
  background: transparent;
}

.theme-list {
  display: grid;
  gap: 0.7rem;
  margin: 0;
  inline-size: max-content;
  max-inline-size: min(28rem, calc(100vw - 2rem));
  padding: 0.75rem;
}

.theme-list fieldset {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem;
  margin: 0;
  max-inline-size: 100%;
  border: 0;
  padding: 0;
}

.theme-list legend {
  flex: 0 0 100%;
  color: var(--muted);
  font-size: 0.7rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  line-height: 1.2;
  text-transform: uppercase;
}

.theme-list input,
.index-pane > .index-order-input {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.theme-list label {
  cursor: pointer;
  border: 1px solid var(--line);
  padding: 0.2rem 0.45rem;
  color: var(--muted);
  font-size: 0.75rem;
  font-weight: 700;
  line-height: 1.3;
  white-space: nowrap;
}

.theme-list label:hover {
  border-color: var(--strong);
  color: var(--strong);
}

.theme-list input:checked + label {
  border-color: var(--index-selected-border);
  background: var(--index-selected-bg);
  color: var(--index-selected-text);
}

.theme-list input:checked + label:hover {
  border-color: var(--strong);
}

.shell {
  display: grid;
  grid-template-columns: 16rem minmax(0, 1fr);
  min-block-size: calc(100vh - var(--header-height));
  min-block-size: calc(100svh - var(--header-height));
  min-block-size: calc(100dvh - var(--header-height));
}

.index-pane {
  position: sticky;
  top: var(--header-height);
  display: flex;
  flex-direction: column;
  align-self: start;
  block-size: calc(100vh - var(--header-height));
  block-size: calc(100svh - var(--header-height));
  block-size: calc(100dvh - var(--header-height));
  overflow: hidden;
  border-inline-end: 1px solid var(--line);
  background: var(--panel-bg);
  padding: 1rem;
  padding-block-end: 0.5rem;
  scrollbar-color: var(--line) var(--panel-bg);
}

.index-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  margin-block-end: 0.75rem;
}

.index-head h2 {
  margin: 0;
  color: var(--muted);
  font-size: 0.75rem;
  letter-spacing: 0.08em;
  line-height: 1.2;
  text-transform: uppercase;
}

.index-order {
  display: flex;
  flex-wrap: wrap;
  gap: 0.18rem;
}

.index-order label {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-inline-size: 1.5rem;
  min-block-size: 1.5rem;
  cursor: pointer;
  border: 0;
  padding: 0.08rem;
  background: transparent;
  color: var(--muted);
  font-size: 0.68rem;
  font-weight: 700;
  line-height: 1.25;
  text-align: center;
  text-transform: uppercase;
}

.index-order label:hover {
  color: var(--strong);
  text-decoration-line: underline;
  text-decoration-thickness: 0.12em;
  text-underline-offset: 0.2em;
}

#index-order-title:checked ~ .index-head label[for="index-order-title"],
#index-order-created:checked ~ .index-head label[for="index-order-created"],
#index-order-modified:checked ~ .index-head label[for="index-order-modified"] {
  color: var(--blue);
  text-decoration-line: underline;
  text-decoration-thickness: 0.12em;
  text-underline-offset: 0.2em;
}

.index-pane .index-list {
  display: none;
  align-content: start;
  grid-auto-rows: max-content;
  gap: 0.28rem;
  flex: 0 1 auto;
  overflow: auto;
  max-block-size: 40%;
  min-block-size: 0;
  margin: 0;
  padding: 0 0.25rem 0 0;
  list-style: none;
  overscroll-behavior: contain;
  scrollbar-color: var(--line) var(--panel-bg);
  scrollbar-width: thin;
}

.index-pane > #index-order-title:checked ~ .index-list-title,
.index-pane > #index-order-created:checked ~ .index-list-created,
.index-pane > #index-order-modified:checked ~ .index-list-modified {
  display: grid;
}

.index-item {
  min-inline-size: 0;
}

.index-section-lists {
  display: none;
  flex: 1 1 auto;
  flex-direction: column;
  min-block-size: 0;
  margin-block-start: 0.75rem;
  border-block-start: 1px solid var(--line);
  padding-block-start: 0.75rem;
}

.index-subsection-lists {
  display: none;
}

.index-section-list {
  display: none;
  flex: 1 1 auto;
  flex-direction: column;
  gap: 0.06rem;
  overflow: auto;
  min-block-size: 0;
  margin: 0;
  padding: 0 0.25rem 0 0.75rem;
  list-style: none;
  overscroll-behavior: contain;
  scrollbar-color: var(--line) var(--panel-bg);
  scrollbar-width: thin;
}

.index-section-item {
  min-inline-size: 0;
}

.index-subsection-list {
  display: none;
  flex-direction: column;
  gap: 0.04rem;
  margin: 0;
  padding: 0 0.2rem 0 0.8rem;
  list-style: none;
}

.index-pane a {
  display: block;
  border-inline-start: 3px solid transparent;
  padding: 0.4rem 0.55rem;
  color: var(--muted);
  text-decoration: none;
}

.index-section-item > a {
  display: flex;
  align-items: center;
  min-block-size: 1.5rem;
  border-inline-start-width: 2px;
  padding: 0.13rem 0.45rem;
  color: var(--base01);
  font-size: 0.66rem;
  line-height: 1.25;
}

.index-subsection-list a {
  display: flex;
  align-items: center;
  min-block-size: 1.35rem;
  border-inline-start-width: 2px;
  padding: 0.1rem 0.4rem;
  color: var(--base01);
  font-size: 0.58rem;
  line-height: 1.2;
}

.index-pane a:hover {
  border-inline-start-color: var(--yellow);
  color: var(--strong);
}

.index-footer {
  flex: none;
  margin-block-start: auto;
  border-block-start: 1px solid var(--line);
  padding-block-start: 0.5rem;
  color: var(--base01);
  font-size: 0.68rem;
  line-height: 1.35;
  text-align: right;
}

.index-footer p {
  margin: 0.15rem 0;
}

.index-footer a:hover {
  color: var(--strong);
}

.index-footer a,
.contact-button,
.license-button {
  appearance: none;
  -webkit-appearance: none;
  color: inherit;
  border: 0;
  background: transparent;
  padding: 0;
  font: inherit;
  cursor: pointer;
  text-decoration-color: var(--line);
  text-underline-offset: 0.18em;
}

.index-footer-actions {
  display: inline-flex;
  align-items: center;
  justify-content: flex-end;
  gap: 0.55rem;
}

.contact-button,
.license-button {
  display: inline-flex;
  align-items: center;
  min-block-size: 1.5rem;
  text-decoration-line: underline;
}

.contact-button:hover,
.license-button:hover {
  color: var(--strong);
}

.article-pane {
  min-inline-size: 0;
  padding: clamp(1.25rem, 4vw, 3rem);
}

.article {
  display: none;
  max-inline-size: 96rem;
  margin-inline: auto;
}

.article-header,
.article > .article-downloads,
.article > section > * {
  max-inline-size: 88ch;
  margin-inline: auto;
}

.article:first-of-type {
  display: block;
}

.article-pane:has(.article:target) .article,
.article-pane:has(.article :target) .article {
  display: none;
}

.article-pane:has(.article:target) .article:target,
.article-pane:has(.article :target) .article:has(:target) {
  display: block;
  scroll-margin-top: calc(var(--header-height) + 1rem);
}

.article[id],
.article h2[id],
.article h3[id] {
  scroll-margin-top: calc(var(--header-height) + 1rem);
}

.article-header {
  border-block-end: 1px solid var(--line);
  margin-block-end: 2rem;
  padding-block-end: 1rem;
}

.kicker {
  margin: 0 0 0.4rem;
  color: var(--green);
  font-size: 0.75rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  line-height: 1.2;
  text-transform: uppercase;
}

h1,
h2,
h3 {
  color: var(--strong);
  line-height: 1.2;
}

h1 {
  margin: 0;
  font-size: 3rem;
}

h2 {
  margin: 2rem 0 0.6rem;
  font-size: 1.2rem;
}

h3 {
  margin: 1.5rem 0 0.45rem;
  font-size: 1rem;
}

p {
  margin: 0.85rem 0;
}

.summary {
  color: var(--muted);
  font-size: 1.05rem;
}

.article-downloads {
  display: grid;
  gap: 0.55rem;
  margin: 0.75rem 0 1rem;
}

.article-download {
  position: relative;
  z-index: 0;
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: 0.2rem 0.6rem;
  border: 1px solid var(--code-border);
  border-radius: 6px;
  background: var(--code-title-bg);
  color: var(--code-title-text);
  padding: 0.5rem 0.7rem;
  text-decoration: none;
}

.article-download-hit {
  position: absolute;
  z-index: 1;
  inset: 0;
  border-radius: inherit;
}

.article-downloads + .article-downloads {
  margin-block-start: -0.45rem;
}

.article-download:not(.article-download-missing) .article-download-kind::before {
  content: "↓";
  color: var(--blue);
  font-weight: 700;
}

.article-download-kind {
  display: inline-flex;
  position: relative;
  z-index: 2;
  grid-row: 1 / 3;
  grid-column: 1;
  align-items: center;
  gap: 0.6rem;
  color: var(--base01);
  font-size: 0.75rem;
  font-weight: 700;
  line-height: 1;
  pointer-events: none;
  text-transform: uppercase;
}

.article-download code {
  color: inherit;
  font: 0.85rem/1.3 var(--font-mono);
}

.article p code,
.article li code {
  overflow-wrap: anywhere;
}

.article-download:not(.article-download-missing) .block-label {
  position: relative;
  z-index: 2;
  grid-row: 1;
  grid-column: 2;
  min-inline-size: 0;
  pointer-events: none;
}

.article-download .block-checksum {
  position: relative;
  z-index: 2;
  grid-row: 2;
  grid-column: 2;
  justify-self: stretch;
  pointer-events: auto;
  text-align: start;
  user-select: text;
}

.article-download:not(.article-download-missing) .block-size {
  position: relative;
  z-index: 2;
  grid-row: 1 / 3;
  grid-column: 3;
  align-self: center;
  pointer-events: none;
}

.article-download:hover {
  border-color: var(--download-hover-border);
  color: var(--strong);
}

.article-download-missing::before {
  content: "!";
  color: var(--red);
  font-weight: 700;
}

.article-download-missing {
  display: flex;
}

.code-details,
.media-details {
  min-inline-size: 0;
  margin: 1rem 0 1.35rem;
}

.code-details summary,
.media-details summary {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  cursor: pointer;
  border: 1px solid var(--code-border);
  border-block-end: 0;
  border-radius: 6px 6px 0 0;
  background: var(--code-title-bg);
  color: var(--code-title-text);
  padding: 0.45rem 0.7rem;
  list-style: none;
}

.code-details:not([open]) summary,
.media-details:not([open]) summary {
  border-block-end: 1px solid var(--code-border);
  border-radius: 6px;
}

.code-details summary[id],
.media-details summary[id],
.block-footer[id] {
  scroll-margin-top: calc(var(--header-height) + 1rem);
}

.code-details summary::-webkit-details-marker,
.media-details summary::-webkit-details-marker {
  display: none;
}

.code-details summary::before,
.media-details summary::before {
  content: "▸";
  color: var(--blue);
}

.code-details[open] summary::before,
.media-details[open] summary::before {
  content: "▾";
}

.code-details summary:hover,
.media-details summary:hover {
  color: var(--strong);
}

.code-details summary:focus-visible,
.media-details summary:focus-visible {
  background: var(--code-bg);
  color: var(--strong);
  outline: 2px solid var(--strong);
  outline-offset: -2px;
}

.code-details summary:target,
.media-details summary:target,
.block-footer:target {
  background: var(--code-title-bg);
  color: var(--code-title-text);
  outline: 1px solid var(--line);
  outline-offset: -1px;
  animation: block-target-outline 1.2s ease-out forwards;
}

@keyframes block-target-outline {
  to {
    outline-color: transparent;
  }
}

.block-action:focus-visible {
  background: var(--line);
  color: var(--strong);
  outline: 2px solid var(--strong);
  outline-offset: 2px;
}

.code-details summary code,
.media-details summary code {
  min-inline-size: 0;
  color: inherit;
  font: inherit;
  overflow-wrap: anywhere;
}

.block-label {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.25rem 0.45rem;
  min-inline-size: 0;
}

.block-title {
  min-inline-size: 0;
  color: inherit;
  font: 0.85rem/1.3 var(--font-mono);
  overflow-wrap: anywhere;
}

.block-note {
  color: var(--base01);
  font: 0.76rem/1.3 var(--font-ui);
  font-size: clamp(0.76rem, calc(0.7rem + 0.1vw), 0.8rem);
  overflow-wrap: anywhere;
}

.block-note::before {
  content: ":";
  margin-inline-end: 0.45rem;
  color: var(--base01);
}

.block-action {
  display: inline-flex;
  align-items: center;
  color: var(--blue);
  font: 0.78rem/1.3 var(--font-ui);
  line-height: 1;
  text-decoration: none;
}

.block-action:hover {
  color: var(--strong);
}

.code-download {
  margin-inline-start: auto;
}

.block-size {
  display: inline-flex;
  align-items: center;
  margin-inline-start: auto;
  color: var(--base01);
  font: 0.72rem/1.25 var(--font-mono);
  font-variant-numeric: tabular-nums;
  line-height: 1;
  white-space: nowrap;
}

.block-checksum {
  display: block;
  min-inline-size: 0;
  color: var(--base01);
  font: 0.52rem/1.2 var(--font-mono);
  font-size: clamp(0.52rem, calc(0.4rem + 0.2vw), 0.64rem);
  font-variant-numeric: tabular-nums;
  overflow: hidden;
  text-align: end;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.block-footer {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  border: 1px solid var(--code-border);
  border-block-start: 0;
  border-radius: 0 0 6px 6px;
  background: var(--code-title-bg);
  color: var(--code-title-text);
  padding: 0.4rem 0.7rem;
}

.block-footer .block-label {
  flex: 1 1 auto;
  color: var(--base01);
}

.block-footer-link {
  font: 0.78rem/1.3 var(--font-ui);
  line-height: 1;
}

.block-jump {
  font-weight: 700;
}

.code-details:not([open]) summary .block-jump,
.media-details:not([open]) summary .block-jump {
  display: none;
}

.code-block {
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
  min-inline-size: 0;
  max-inline-size: 100%;
  overflow: hidden;
  margin: 0;
  border: 1px solid var(--code-border);
  border-radius: 0;
  background: var(--code-bg);
  color: var(--syntax-plain);
}

.code-block-standalone {
  margin-block: 1rem 1.35rem;
  border-radius: 6px;
}

.block-standalone {
  min-inline-size: 0;
  margin: 1rem 0 1.35rem;
}

.block-standalone .code-block,
.block-standalone .image-block {
  border-radius: 6px 6px 0 0;
}

.code-details:not(:has(.block-footer)) .code-block {
  border-radius: 0 0 6px 6px;
}

.code-block code {
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
  display: block;
  min-inline-size: 0;
  overflow: auto;
  max-inline-size: 100%;
  padding: 1rem;
  font: 0.92rem/1.55 var(--font-mono);
  tab-size: 2;
  white-space: pre;
}

.code-block code * {
  font-size: inherit;
}

.code-block-missing {
  border-color: var(--red);
}

.image-block {
  display: grid;
  place-items: center;
  gap: 0.7rem;
  overflow: auto;
  min-block-size: var(--image-min-height);
  margin: 0;
  border: 1px solid var(--code-border);
  border-radius: 0;
  background: var(--code-bg);
  color: var(--code-text);
  padding: 1rem;
}

.image-block-standalone {
  margin-block: 1rem 1.35rem;
  border-radius: 6px;
}

.media-details:not(:has(.block-footer)) .image-block {
  border-radius: 0 0 6px 6px;
}

.image-block img {
  display: block;
  inline-size: auto;
  block-size: auto;
  min-block-size: var(--image-min-height);
  max-block-size: min(var(--image-max-height), 70vh);
  max-inline-size: 100%;
  object-fit: contain;
}

.image-block figcaption {
  max-inline-size: 60ch;
  color: var(--muted);
  font-size: 0.85rem;
  line-height: 1.4;
  text-align: center;
}

.image-block-missing {
  border-color: var(--red);
  color: var(--red);
}

.image-block-missing p {
  margin: 0;
}

.sh-normal {
  color: var(--syntax-plain);
}

.sh-comment {
  color: var(--syntax-muted);
  font-style: italic;
}

.sh-keyword {
  color: var(--syntax-green);
  font-weight: 700;
}

.sh-type {
  color: var(--syntax-violet);
}

.sh-preproc {
  color: var(--syntax-magenta);
}

.sh-string,
.sh-regexp,
.sh-specialchar {
  color: var(--syntax-cyan);
}

.sh-usertype,
.sh-classname {
  color: var(--syntax-violet);
  font-weight: 700;
}

.sh-function {
  color: var(--syntax-blue);
}

.sh-number,
.sh-value {
  color: var(--syntax-orange);
}

.sh-variable,
.sh-selector,
.sh-property {
  color: var(--syntax-yellow);
}

.sh-symbol,
.sh-cbracket {
  color: var(--syntax-muted);
}

.sh-error {
  color: var(--syntax-red);
}

.sh-underline {
  text-decoration: underline;
}

.code-block .w,
.code-block .esc,
.code-block .x,
.code-block .n,
.code-block .l,
.code-block .ld,
.code-block .g {
  color: var(--syntax-plain);
}

.code-block .c,
.code-block .c1,
.code-block .ch,
.code-block .cm,
.code-block .cs {
  color: var(--syntax-muted);
  font-style: italic;
}

.code-block .cp,
.code-block .cpf {
  color: var(--syntax-magenta);
}

.code-block .go {
  color: var(--syntax-muted);
}

.code-block .gp {
  color: var(--syntax-blue);
  font-weight: 700;
}

.code-block .k,
.code-block .kc,
.code-block .kd,
.code-block .kn,
.code-block .kp,
.code-block .kr,
.code-block .ow {
  color: var(--syntax-green);
}

.code-block .k,
.code-block .kc,
.code-block .kd,
.code-block .kn,
.code-block .kr {
  font-weight: 700;
}

.code-block .kt {
  color: var(--syntax-violet);
}

.code-block .s,
.code-block .s1,
.code-block .s2,
.code-block .sa,
.code-block .sb,
.code-block .sc,
.code-block .dl,
.code-block .sd,
.code-block .se,
.code-block .sh,
.code-block .si,
.code-block .sr,
.code-block .ss,
.code-block .sx {
  color: var(--syntax-cyan);
}

.code-block .se,
.code-block .si {
  font-weight: 700;
}

.code-block .nf,
.code-block .fm {
  color: var(--syntax-blue);
}

.code-block .m,
.code-block .mb,
.code-block .mf,
.code-block .mh,
.code-block .mi,
.code-block .mo,
.code-block .il {
  color: var(--syntax-orange);
}

.code-block .na,
.code-block .nl,
.code-block .nv,
.code-block .py,
.code-block .vc,
.code-block .vg,
.code-block .vi,
.code-block .vm {
  color: var(--syntax-yellow);
}

.code-block .nb,
.code-block .bp,
.code-block .nt {
  color: var(--syntax-green);
}

.code-block .nt {
  font-weight: 700;
}

.code-block .nc,
.code-block .nn {
  color: var(--syntax-violet);
  font-weight: 700;
}

.code-block .nd,
.code-block .ni {
  color: var(--syntax-magenta);
}

.code-block .ni {
  font-weight: 700;
}

.code-block .ne {
  color: var(--syntax-red);
  font-weight: 700;
}

.code-block .no {
  color: var(--syntax-orange);
}

.code-block .nx {
  color: var(--syntax-plain);
}

.code-block .o,
.code-block .p,
.code-block .pm {
  color: var(--syntax-muted);
}

.code-block .err {
  color: var(--syntax-red);
}

.code-block .gd,
.code-block .gr,
.code-block .gt {
  color: var(--syntax-red);
}

.code-block .gi {
  color: var(--syntax-green);
}

.code-block .gh {
  color: var(--syntax-blue);
  font-weight: 700;
}

.code-block .gu {
  color: var(--syntax-violet);
  font-weight: 700;
}

.code-block .ge,
.code-block .ges {
  font-style: italic;
}

.code-block .ges,
.code-block .gs {
  font-weight: 700;
}

.code-block[data-title="makefile-objects.mk"] .err,
.code-block[data-title="makefile-variables.mk"] .err {
  color: var(--syntax-magenta);
}

@media (max-width: 960px), (pointer: coarse) {
  .block-action {
    justify-content: center;
    min-block-size: 1.5rem;
    padding-inline: 0.2rem;
  }

  .block-jump {
    min-inline-size: 1.5rem;
    font-size: 0.82rem;
  }
}

@media (max-width: 760px) {
  :root {
    --header-height: 2.75rem;
    --image-min-height: 9rem;
    --image-max-height: 22rem;
    --mobile-index-height: 10rem;
  }

  body {
    font-size: 0.9rem;
    line-height: 1.55;
  }

  .site-header {
    gap: 0.5rem;
  }

  .brand {
    gap: 0.45rem;
    min-inline-size: 0;
    padding-inline: 0.75rem;
    font-size: 0.95rem;
  }

  .brand-mark {
    font-size: 1.35rem;
  }

  .theme-button {
    margin-inline-end: 0.75rem;
    padding: 0.22rem 0.45rem;
    font-size: 0.72rem;
  }

  h1 {
    font-size: 1.6rem;
  }

  h2 {
    font-size: 1rem;
  }

  h3 {
    font-size: 0.88rem;
  }

  .summary {
    font-size: 0.9rem;
  }

  .shell {
    grid-template-columns: 1fr;
  }

  .index-pane {
    position: sticky;
    top: var(--header-height);
    z-index: 1;
    block-size: var(--mobile-index-height);
    border-block-end: 1px solid var(--line);
    border-inline-end: 0;
    padding: 0.6rem 0.85rem;
  }

  .index-head {
    margin-block-end: 0.4rem;
  }

  .index-pane .index-list {
    gap: 0.18rem;
  }

  .index-pane a {
    padding: 0.22rem 0.45rem;
  }

  .index-section-list a {
    padding-block: 0.08rem;
    font-size: 0.6rem;
  }

  .index-footer {
    display: flex;
    flex-wrap: nowrap;
    justify-content: flex-end;
    gap: 0.35rem;
    margin-block-start: 0.35rem;
    padding-block-start: 0.35rem;
    overflow: hidden;
    font-size: clamp(0.38rem, 1.75vw, 0.5rem);
    line-height: 1.25;
    white-space: nowrap;
  }

  .index-footer p {
    margin: 0;
  }

  .index-footer .contact-button,
  .index-footer .license-button {
    text-decoration-thickness: 1px;
    text-underline-offset: 0.08em;
  }

  .article-pane {
    padding: 0.85rem;
  }

  .article-pane:has(.article:target) .article:target,
  .article-pane:has(.article :target) .article:has(:target),
  .article[id],
  .article h2[id],
  .article h3[id],
  .code-details summary[id],
  .media-details summary[id],
  .block-footer[id] {
    scroll-margin-top: calc(var(--header-height) + var(--mobile-index-height) + 0.75rem);
  }

  .article-header {
    margin-block-end: 1.1rem;
    padding-block-end: 0.85rem;
  }

  .article-download {
    grid-template-columns: auto minmax(0, 1fr) auto;
    gap: 0.16rem 0.4rem;
    padding: 0.38rem 0.5rem;
  }

  .article-download-kind {
    gap: 0.3rem;
    font-size: 0.58rem;
  }

  .article-download code,
  .block-title {
    font-size: 0.7rem;
  }

  .block-note {
    font-size: 0.64rem;
  }

  .block-size,
  .block-action,
  .block-footer-link {
    font-size: 0.68rem;
  }

  .block-jump {
    font-size: 0.82rem;
  }

  .block-checksum {
    font-size: 0.46rem;
  }

  .code-details summary,
  .media-details summary,
  .block-footer {
    gap: 0.35rem;
    padding: 0.32rem 0.45rem;
  }

  .code-block code {
    padding: 0.6rem;
    font-size: 0.68rem;
    line-height: 1.35;
  }

  .image-block {
    padding: 0.6rem;
  }
}

@media (max-width: 760px) and (orientation: portrait) {
  :root {
    --mobile-index-height: 9rem;
  }

  .index-pane {
    display: grid;
    grid-template-rows: auto 2.625rem auto 1.125rem;
    align-content: start;
    gap: 0.1875rem;
    padding: 0.25rem 0.65rem;
    padding-block-end: 0.125rem;
  }

  .index-head {
    grid-row: 1;
    margin-block-end: 0;
  }

  .index-pane .index-list {
    grid-row: 2;
    align-items: flex-start;
    gap: 0.35rem;
    flex: none;
    block-size: 100%;
    overflow-x: auto;
    overflow-y: hidden;
    max-block-size: none;
    min-block-size: 0;
    padding: 2px;
    overscroll-behavior-x: contain;
    overscroll-behavior-y: auto;
    white-space: nowrap;
  }

  .index-pane > #index-order-title:checked ~ .index-list-title,
  .index-pane > #index-order-created:checked ~ .index-list-created,
  .index-pane > #index-order-modified:checked ~ .index-list-modified {
    display: flex;
  }

  .index-item {
    flex: none;
    min-inline-size: max-content;
  }

  .index-section-lists {
    grid-row: 3;
    flex: none;
    block-size: 2.75rem;
    min-inline-size: 0;
    margin-block-start: 0;
    padding-block-start: 0.25rem;
  }

  .index-subsection-lists {
    grid-row: 4;
    flex: none;
    flex-direction: column;
    block-size: 2.75rem;
    min-inline-size: 0;
    border-block-start: 1px solid var(--line);
    padding-block-start: 0.25rem;
  }

  .index-section-list,
  .index-subsection-lists > .index-subsection-list {
    align-items: flex-start;
    flex: none;
    flex-direction: row;
    gap: 0.3rem;
    block-size: 100%;
    overflow-x: auto;
    overflow-y: hidden;
    min-block-size: 0;
    margin: 0;
    padding: 2px;
    list-style: none;
    overscroll-behavior-x: contain;
    overscroll-behavior-y: auto;
    scrollbar-color: var(--line) var(--panel-bg);
    scrollbar-width: thin;
    white-space: nowrap;
  }

  .index-section-list > li,
  .index-subsection-lists > .index-subsection-list > li {
    flex: none;
  }

  .index-section-list > .index-section-item > .index-subsection-list {
    display: none !important;
  }

  .index-pane .index-article,
  .index-section-item > a,
  .index-subsection-lists .index-subsection-list a {
    display: flex;
    align-items: center;
    flex: none;
    min-block-size: 1.5rem;
    border: 1px solid var(--line);
    padding: 0.15rem 0.5rem;
    line-height: 1.2;
    white-space: nowrap;
  }

  .index-pane .index-article {
    min-block-size: 1.625rem;
  }

  .index-section-item > a {
    color: var(--base01);
    font-size: 0.6rem;
  }

  .index-subsection-lists .index-subsection-list a {
    color: var(--base01);
    font-size: 0.56rem;
  }

  .index-pane .index-article:hover,
  .index-section-item > a:hover,
  .index-subsection-list a:hover {
    border-color: var(--yellow);
    color: var(--strong);
  }

  .index-pane .index-list a:focus-visible,
  .index-section-list a:focus-visible,
  .index-subsection-list a:focus-visible {
    outline-offset: -2px;
  }

  .index-footer {
    grid-row: 4;
    align-items: center;
    justify-content: flex-start;
    gap: 0.2rem;
    block-size: 100%;
    min-inline-size: 0;
    margin-block-start: 0;
    padding-block-start: 0;
    overflow: hidden;
    font-size: clamp(0.35rem, 1.5vw, 0.5rem);
  }

  .index-footer p {
    flex: none;
  }

  .index-footer p:first-child {
    margin-inline-start: auto;
  }

  .index-footer .contact-button,
  .index-footer .license-button {
    min-block-size: 1rem;
  }
}

@media (max-width: 960px) and (orientation: landscape) {
  :root {
    --header-height: 2.75rem;
  }

  body {
    font-size: 0.9rem;
    line-height: 1.55;
  }

  .brand {
    padding-inline: 0.75rem;
    font-size: 0.95rem;
  }

  .brand-mark {
    font-size: 1.35rem;
  }

  .theme-button {
    margin-inline-end: 0.75rem;
    padding: 0.22rem 0.45rem;
    font-size: 0.72rem;
  }

  .shell {
    grid-template-columns: 12rem minmax(0, 1fr);
  }

  .index-pane {
    block-size: calc(100vh - var(--header-height));
    block-size: calc(100svh - var(--header-height));
    block-size: calc(100dvh - var(--header-height));
    border-block-end: 0;
    border-inline-end: 1px solid var(--line);
    padding: 0.75rem;
    padding-block-end: 0.5rem;
  }

  .index-pane a {
    padding-block: 0.24rem;
  }

  .index-pane .index-article {
    font-size: 0.99rem;
  }

  .index-section-item > a {
    font-size: 0.77rem;
  }

  .index-subsection-list a {
    font-size: 0.66rem;
  }

  .index-footer {
    display: block;
    margin-block-start: 0.75rem;
    padding-block-start: 0.5rem;
    font-size: 0.62rem;
    line-height: 1.35;
    white-space: normal;
  }

  .index-footer p {
    margin: 0.15rem 0;
  }

  .article-pane {
    padding: 1rem 1.25rem;
  }

  h1 {
    font-size: 1.95rem;
  }

  .summary {
    font-size: 0.9rem;
  }

  .code-block code {
    padding: 0.6rem;
    font-size: 0.7rem;
    line-height: 1.35;
  }

  .article-pane:has(.article:target) .article:target,
  .article-pane:has(.article :target) .article:has(:target),
  .article[id],
  .article h2[id],
  .article h3[id],
  .code-details summary[id],
  .media-details summary[id],
  .block-footer[id] {
    scroll-margin-top: calc(var(--header-height) + 1rem);
  }
}

@media (orientation: landscape) {
  .index-pane .index-list {
    flex: 1 1 0;
    max-block-size: max-content;
  }

  .index-pane .index-list:has(> .index-item:nth-child(2)) {
    min-block-size: 4.375rem;
  }

  .index-section-lists {
    flex: 2 1 0;
    min-block-size: 4.25rem;
  }
}

@media (min-width: 961px) and (orientation: landscape) {
  .index-pane .index-list:has(> .index-item:nth-child(2)) {
    min-block-size: 5.625rem;
  }

  .index-pane .index-article {
    font-size: 1.1rem;
  }

  .index-section-item > a {
    font-size: 0.825rem;
  }

  .index-subsection-list a {
    font-size: 0.715rem;
  }
}

@media (max-height: 480px) and (orientation: landscape) {
  .index-section-lists {
    margin-block-start: 0.4rem;
    padding-block-start: 0.4rem;
  }
}

@media (max-height: 320px) and (orientation: landscape) {
  .index-head {
    margin-block-end: 0.25rem;
  }

  .index-footer {
    margin-block-start: 0.35rem;
    padding-block-start: 0.4rem;
    font-size: 0.56rem;
  }

  .index-footer p {
    margin-block: 0.1rem;
  }
}

@media (max-width: 360px) and (orientation: portrait),
       (max-height: 330px) and (orientation: landscape) {
  .index-footer p:nth-child(2),
  .index-footer p:nth-child(3) {
    display: none;
  }
}

@media (max-width: 360px) {
  h1 {
    font-size: 1.42rem;
  }

  .article-pane {
    padding-inline: 0.7rem;
  }

  .code-block code {
    font-size: 0.64rem;
  }
}
tools/theme-menu.htmlTheme selector markupraw
      <button class="theme-button" type="button" popovertarget="theme-popover">Theme</button>
      <div class="theme-menu" id="theme-popover" popover>
        <div class="theme-head">
          <h2>Theme</h2>
          <button class="theme-close" type="button" popovertarget="theme-popover" popovertargetaction="hide" aria-label="Close theme menu">X</button>
        </div>
        <form class="theme-list" aria-label="Theme">
          <fieldset>
            <legend>Solarized</legend>
            <input type="radio" name="theme" id="theme-solarized-dark" checked>
            <label for="theme-solarized-dark">Dark</label>
            <input type="radio" name="theme" id="theme-solarized-light">
            <label for="theme-solarized-light">Light</label>
          </fieldset>
          <fieldset>
            <legend>Selenized</legend>
            <input type="radio" name="theme" id="theme-selenized-dark">
            <label for="theme-selenized-dark">Dark</label>
            <input type="radio" name="theme" id="theme-selenized-black">
            <label for="theme-selenized-black">Black</label>
            <input type="radio" name="theme" id="theme-selenized-light">
            <label for="theme-selenized-light">Light</label>
            <input type="radio" name="theme" id="theme-selenized-white">
            <label for="theme-selenized-white">White</label>
          </fieldset>
          <fieldset>
            <legend>OKSolar</legend>
            <input type="radio" name="theme" id="theme-oksolar-dark">
            <label for="theme-oksolar-dark">Dark</label>
            <input type="radio" name="theme" id="theme-oksolar-light">
            <label for="theme-oksolar-light">Light</label>
          </fieldset>
          <fieldset>
            <legend>Gruvbox</legend>
            <input type="radio" name="theme" id="theme-gruvbox-dark">
            <label for="theme-gruvbox-dark">Dark</label>
            <input type="radio" name="theme" id="theme-gruvbox-light">
            <label for="theme-gruvbox-light">Light</label>
          </fieldset>
          <fieldset>
            <legend>Tomorrow</legend>
            <input type="radio" name="theme" id="theme-tomorrow">
            <label for="theme-tomorrow">Light</label>
            <input type="radio" name="theme" id="theme-tomorrow-night">
            <label for="theme-tomorrow-night">Night</label>
            <input type="radio" name="theme" id="theme-tomorrow-eighties">
            <label for="theme-tomorrow-eighties">Eighties</label>
            <input type="radio" name="theme" id="theme-tomorrow-blue">
            <label for="theme-tomorrow-blue">Blue</label>
            <input type="radio" name="theme" id="theme-tomorrow-bright">
            <label for="theme-tomorrow-bright">Bright</label>
          </fieldset>
          <fieldset>
            <legend>Zenburn</legend>
            <input type="radio" name="theme" id="theme-zenburn">
            <label for="theme-zenburn">Dark</label>
          </fieldset>
          <fieldset>
            <legend>Nord</legend>
            <input type="radio" name="theme" id="theme-nord">
            <label for="theme-nord">Dark</label>
          </fieldset>
          <fieldset>
            <legend>Everforest</legend>
            <input type="radio" name="theme" id="theme-everforest-dark">
            <label for="theme-everforest-dark">Dark</label>
            <input type="radio" name="theme" id="theme-everforest-light">
            <label for="theme-everforest-light">Light</label>
          </fieldset>
          <fieldset>
            <legend>Ayu</legend>
            <input type="radio" name="theme" id="theme-ayu-dark">
            <label for="theme-ayu-dark">Dark</label>
            <input type="radio" name="theme" id="theme-ayu-mirage">
            <label for="theme-ayu-mirage">Mirage</label>
            <input type="radio" name="theme" id="theme-ayu-light">
            <label for="theme-ayu-light">Light</label>
          </fieldset>
          <fieldset>
            <legend>Catppuccin</legend>
            <input type="radio" name="theme" id="theme-catppuccin-latte">
            <label for="theme-catppuccin-latte">Latte</label>
            <input type="radio" name="theme" id="theme-catppuccin-frappe">
            <label for="theme-catppuccin-frappe">Frappe</label>
            <input type="radio" name="theme" id="theme-catppuccin-macchiato">
            <label for="theme-catppuccin-macchiato">Macchiato</label>
            <input type="radio" name="theme" id="theme-catppuccin-mocha">
            <label for="theme-catppuccin-mocha">Mocha</label>
          </fieldset>
          <fieldset>
            <legend>Rose Pine</legend>
            <input type="radio" name="theme" id="theme-rose-pine">
            <label for="theme-rose-pine">Main</label>
            <input type="radio" name="theme" id="theme-rose-pine-moon">
            <label for="theme-rose-pine-moon">Moon</label>
            <input type="radio" name="theme" id="theme-rose-pine-dawn">
            <label for="theme-rose-pine-dawn">Dawn</label>
          </fieldset>
        </form>
      </div>
tools/theme-menu.html5.0 KiBraw
tools/license.txtLicense popover templateraw
BSD 3-Clause

Copyright (c) <YEAR>, <COPYRIGHT_HOLDER>

Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:

1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.

2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation and/or
other materials provided with the distribution.

3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software without
specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT INCLUDING NEGLIGENCE OR OTHERWISE ARISING IN ANY WAY OUT OF THE USE OF
THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
tools/license.txt1.4 KiBraw
tools/html-fragment.outlangSource-highlight fragment outputraw
extension "html"

onestyle "<span class=\"sh-$style\">$text</span>"
bold "<strong>$text</strong>"
italics "<em>$text</em>"
underline "<span class=\"sh-underline\">$text</span>"

translations
"&" "&amp;"
"<" "&lt;"
">" "&gt;"
end

nodoctemplate
"$header"
"$footer"
end
articles/*/article.htmlArticle source fragmentsraw
<article class="article" id="getting-started" data-title="Getting Started" data-created="2026-07-09" data-modified="2026-07-09">
  <header class="article-header">
    <p class="kicker">Guide</p>
    <h1>Getting Started</h1>
    <p class="summary">
      A short article built from one editable HTML fragment.
    </p>
  </header>

  <section>
    <h2>Example</h2>
    <p>Article text is normal trusted HTML.</p>
    <h3>Subsection</h3>
    <p>Plain one-line h3 headings appear under their preceding section.</p>
  </section>
</article>

BUILD

Run the build from the project root. Settings may be supplied through the environment or make command line. Make passes them through the recipe environment; defaults remain in tools/build.sh.

Build commandraw
make build

# or with defined settings

make build \
    SITE_TITLE=arachnopress \
    SITE_MARK=\
    GENERATOR_NAME=arachnopress \
    GENERATOR_VERSION=1.0.19 \
    DEFAULT_THEME=solarized-dark \
    ARTICLE_ORDER=title \
    HIGHLIGHTER=pygments

SETTINGS

The build accepts site identity, separate generator name and numeric dotted version, default theme, article order, highlighter, automatic code footer threshold, license file, and copyright holder.

Build settingsraw
# Supply these through the environment or on the make command line.
# SITE_TITLE=arachnopress
# SITE_MARK=䷖
# SITE_MARK_COLOR=#2aa198
# GENERATOR_NAME=arachnopress
# GENERATOR_VERSION=1.0.19
# DEFAULT_THEME=solarized-dark
# ARTICLE_ORDER=title
# HIGHLIGHTER=pygments
# CODE_FOOTER_LINES=23
# LICENSE_FILE=
# COPYRIGHT_HOLDER=

ARTICLES

Article format

Each article is stored at articles/<slug>/article.html. Its article id must equal the directory slug and data-title is required. Optional data-created and data-modified dates control sorting. Missing creation dates use the current UTC build date; missing modification dates use the creation date. Interface, article, section, and generated block IDs share one collision registry. The build emits title, creation, and modification order lists.

articles/*/article.htmlExample articleraw
<article class="article" id="getting-started" data-title="Getting Started" data-created="2026-07-09" data-modified="2026-07-09">
  <header class="article-header">
    <p class="kicker">Guide</p>
    <h1>Getting Started</h1>
    <p class="summary">
      A short article built from one editable HTML fragment.
    </p>
  </header>

  <section>
    <h2>Example</h2>
    <p>Article text is normal trusted HTML.</p>
    <h3>Subsection</h3>
    <p>Plain one-line h3 headings appear under their preceding section.</p>
  </section>
</article>

Build process

Generator markers are file-backed, one-line empty elements. Referenced paths may contain subdirectories but must be relative to the article and must not escape it. Displayed sizes and checksums use the raw files.

Article scanraw
for source in "$articles_dir"/*/article.html; do
	[ -f "$source" ] || continue
	dir=${source%/*}
	slug=${dir##*/}

	[ ! -L "$dir" ] || die "Refusing symlink article directory: $dir"
	[ ! -L "$source" ] || die "Refusing symlink article: $source"
	safe_name "$slug" || die "Unsafe article directory name: $slug"

	article_line=$(sed -n '/<article /{p;q;}' "$source")
	write_attrs "$article_line" "$attrs_file" \
		class data-title id data-created data-modified
	{
		IFS= read -r article_class
		IFS= read -r title
		IFS= read -r article_id
		IFS= read -r created
		IFS= read -r modified
	} < "$attrs_file"

	case " $article_class " in
		*' article '*) ;;
		*) die "$source article element must include class=\"article\"." ;;
	esac
	[ -n "$title" ] || die "Missing article data-title in $source"
	[ -n "$created" ] || created=$build_day
	[ -n "$modified" ] || modified=$created
	safe_date "$created" || die "Article data-created must begin YYYY-MM-DD: $source"
	safe_date "$modified" || die "Article data-modified must begin YYYY-MM-DD: $source"
	[ "$article_id" = "$slug" ] || die "$source must contain id=\"$slug\" on its article element."
	reserve_anchor "$slug" "$source"

	printf '%s\t%s\t%s\t%s\n' \
		"$slug" "$title" "$modified" "$created" >> "$article_meta"
	read_sections "$source" "$slug"
done
Render articleraw
render_article() {
	dir=$1
	source=$2
	slug=${dir##*/}
	heading_meta=$tmp/headings-$slug.tsv

	exec 3< "$heading_meta"
	while IFS= read -r line || [ -n "$line" ]; do
		case $line in
			*'<div'*'class="article-downloads"'*)
				require_empty_marker "$source" "$line" div "Download"
				case $line in
					*'data-downloads='*)
						die "Download marker uses obsolete data-downloads; use one data-src per download: $source"
						;;
				esac
				write_attrs "$line" "$attrs_file" \
					data-src data-title data-note
				{
					IFS= read -r data_src
					IFS= read -r data_title
					IFS= read -r data_note
				} < "$attrs_file"
				render_download "$dir" "$data_src" "$data_title" "$data_note"
				;;
			*'<pre'*'class="code-block"'*)
				require_empty_marker "$source" "$line" pre "Code block"
				write_attrs "$line" "$attrs_file" \
					data-src data-lang data-open data-title data-note data-footer data-header
				{
					IFS= read -r data_src
					IFS= read -r data_lang
					IFS= read -r data_open
					IFS= read -r data_title
					IFS= read -r data_note
					IFS= read -r data_footer
					IFS= read -r data_header
				} < "$attrs_file"
				render_code_block "$dir" "$data_src" "$data_lang" \
					"$data_open" "$data_title" "$data_note" "$data_footer" "$data_header"
				;;
			*'<figure'*'class="image-block"'*)
				require_empty_marker "$source" "$line" figure "Image"
				write_attrs "$line" "$attrs_file" \
					data-src data-alt data-caption data-open data-title data-note data-footer data-header
				{
					IFS= read -r data_src
					IFS= read -r data_alt
					IFS= read -r data_caption
					IFS= read -r data_open
					IFS= read -r data_title
					IFS= read -r data_note
					IFS= read -r data_footer
					IFS= read -r data_header
				} < "$attrs_file"
				render_image_block "$dir" "$data_src" "$data_alt" \
					"$data_caption" "$data_open" "$data_title" "$data_note" "$data_footer" "$data_header"
				;;
			*'<h2'*'</h2>'*|*'<h3'*'</h3>'*)
				case $line in
					*'<h2'*) heading_tag=h2 ;;
					*) heading_tag=h3 ;;
				esac
				IFS="$tab" read -r indexed_tag heading_id heading_title parent_id <&3 ||
					die "Heading index mismatch while rendering $source"
				[ "$indexed_tag" = "$heading_tag" ] ||
					die "Heading index mismatch while rendering $source"
				if [ -z "$(attr id "$line")" ]; then
					printf '%s\n' "$line" |
						sed "s/<$heading_tag/<$heading_tag id=\"$heading_id\"/"
				else
					printf '%s\n' "$line"
				fi
				;;
			*)
				printf '%s\n' "$line"
			;;
		esac
	done < "$source"
	if IFS="$tab" read -r indexed_tag heading_id heading_title parent_id <&3; then
		die "Heading index mismatch while rendering $source"
	fi
	exec 3<&-
}

SECTION INDEX

Section headers and scan

Plain one-line h2 and h3 headings are included in the article's section index. Each h3 belongs to the preceding h2. The build preserves an explicit id or generates a safe unique anchor.

Section headersraw
<h2>BUILD</h2>
<h3>Requirements</h3>
<h3 id="custom-subsection">Explicit Subsection Anchor</h3>
<h2>SETTINGS</h2>

<h2 id="custom-anchor">Explicit Anchor</h2>
Section scanraw
slugify() {
	printf '%s\n' "$1" |
		tr '[:upper:]' '[:lower:]' |
		sed -e 's/[^a-z0-9][^a-z0-9]*/-/g' \
		    -e 's/^-//' \
		    -e 's/-$//' \
		    -e 's/^$/section/'
}

heading_text() {
	tag=$1
	printf '%s\n' "$2" |
	awk -v tag="$tag" '
		function decode(s) {
			gsub(/&quot;|&#34;/, "\"", s)
			gsub(/&apos;|&#39;/, "\047", s)
			gsub(/&lt;|&#60;/, "<", s)
			gsub(/&gt;|&#62;/, ">", s)
			gsub(/&amp;|&#38;/, "\\&", s)
			return s
		}
		{
			line = $0
			start = index(line, "<" tag)
			end = index(line, "</" tag ">")
			if (!start || !end || end < start)
				exit
			head = substr(line, start, end - start)
			for (i = 1; i <= length(head); i++)
				if (substr(head, i, 1) == ">") {
					close_pos = i
					break
				}
			if (!close_pos)
				exit
			text = substr(head, close_pos + 1)
			if (text !~ /</)
				print decode(text)
			exit
		}
	'
}

read_sections() {
	source=$1
	slug=$2
	heading_meta=$tmp/headings-$slug.tsv
	section_ids=$tmp/section-ids-$slug.txt
	subsection_meta=$tmp/subsections-$slug.tsv
	subsection_parents=$tmp/subsection-parents-$slug.txt
	: > "$heading_meta"
	: > "$section_ids"
	: > "$subsection_meta"
	: > "$subsection_parents"
	current_section=

	while IFS= read -r line || [ -n "$line" ]; do
		case $line in
			*'<h2'*)
				heading_tag=h2
				closing='</h2>'
				;;
			*'<h3'*)
				heading_tag=h3
				closing='</h3>'
				;;
			*) continue ;;
		esac
		case $line in
			*"$closing"*) ;;
			*) die "Indexable $heading_tag must be plain one-line text: $source" ;;
		esac

		heading_title=$(heading_text "$heading_tag" "$line")
		[ -n "$heading_title" ] ||
			die "Indexable $heading_tag must contain plain one-line text: $source"
		case $heading_title in
			*"$tab"*) die "Heading title may not contain tabs: $source" ;;
		esac

		heading_id=$(attr id "$line")
		if [ -n "$heading_id" ]; then
			safe_name "$heading_id" ||
				die "Unsafe $heading_tag id in $source: $heading_id"
		else
			case $heading_tag in
				h2) anchor_base=$slug-$(slugify "$heading_title") ;;
				h3)
					[ -n "$current_section" ] ||
						die "Indexable h3 must follow an h2: $source"
					anchor_base=$current_section-$(slugify "$heading_title")
					;;
			esac
			heading_id=$(unique_auto_anchor "$anchor_base")
		fi
		reserve_anchor "$heading_id" "$source"

		case $heading_tag in
			h2)
				current_section=$heading_id
				parent_id=
				printf '%s\n' "$heading_id" >> "$section_ids"
				;;
			h3)
				[ -n "$current_section" ] ||
					die "Indexable h3 must follow an h2: $source"
				parent_id=$current_section
				printf '%s\t%s\n' "$parent_id" "$heading_id" >> "$subsection_meta"
				if ! grep -F -x -q -e "$parent_id" "$subsection_parents"; then
					printf '%s\n' "$parent_id" >> "$subsection_parents"
				fi
				subsection_count=$((subsection_count + 1))
				;;
		esac
		printf '%s\t%s\t%s\t%s\n' \
			"$heading_tag" "$heading_id" "$heading_title" "$parent_id" >> "$heading_meta"
	done < "$source"
}

Section index layout and css

Shared index layout is defined in styles.css; nav-sections.css selects the current article and section. Landscape subsections flow within the single vertically scrolling section list. Narrow portrait navigation lists are independent horizontal scrollers with fixed tracks for scrollbar stability, while their metadata remains in a compact non-scrolling row. Landscape labels step down in size from article to section to subsection. A selected subsection uses a quieter palette variation while its parent section retains the primary highlight.

Section index CSSraw
.index-section-lists,
.index-section-list,
.index-subsection-lists,
.index-subsection-list {
  display: none;
}

.index-section-lists {
  flex: 1 1 auto;
  flex-direction: column;
  min-block-size: 0;
  border-block-start: 1px solid var(--line);
}

.index-section-list {
  flex: 1 1 auto;
  flex-direction: column;
  gap: 0.06rem;
  overflow: auto;
  min-block-size: 0;
  margin: 0;
  padding-inline-start: 0.75rem;
  list-style: none;
}

.index-section-item > a {
  display: flex;
  align-items: center;
  min-block-size: 1.5rem;
  border-inline-start-width: 2px;
  padding: 0.13rem 0.45rem;
  color: var(--base01);
  font-size: 0.66rem;
  line-height: 1.25;
}

.index-subsection-list {
  flex-direction: column;
  margin: 0;
  padding-inline-start: 0.8rem;
  list-style: none;
}

.index-subsection-list a {
  display: flex;
  align-items: center;
  min-block-size: 1.35rem;
  padding: 0.1rem 0.4rem;
  color: var(--base01);
  font-size: 0.58rem;
}

.article-pane:has(.article :target) .article {
  display: none;
}

.article-pane:has(.article :target) .article:has(:target) {
  display: block;
}

/* nav-sections.css selects the current article and its shared section list. */
body:has(.article[id="arachnopress"] :target) .index-section-lists,
body:has(.article[id="arachnopress"] :target)
  .index-section-list[data-article="arachnopress"] {
  display: flex;
}

/* Generated selectors also mark the exact targeted section link. */
body:has(.article[id="arachnopress"] [id="arachnopress-build"]:target)
  .index-section-list[data-article="arachnopress"]
  .index-section-item[data-section="arachnopress-build"] >
  a[href="#arachnopress-build"] {
  background: var(--index-selected-bg);
  border-color: var(--index-selected-border);
  color: var(--index-selected-text);
}

/* A targeted subsection uses a quieter variation of the active palette. */
body:has(.article[id="arachnopress"] [id="arachnopress-articles-build-process"]:target)
  .index-subsection-list[data-section="arachnopress-articles"]
  a[href="#arachnopress-articles-build-process"] {
  background: var(--index-subsection-selected-bg);
  border-color: var(--index-subsection-selected-border);
  color: var(--index-subsection-selected-text);
}

@media (max-width: 960px) and (orientation: landscape) {
  .index-pane .index-article {
    font-size: 0.99rem;
  }

  .index-section-item > a {
    font-size: 0.77rem;
  }

  .index-subsection-list a {
    font-size: 0.66rem;
  }
}

@media (min-width: 961px) and (orientation: landscape) {
  .index-pane .index-article {
    font-size: 1.1rem;
  }

  .index-section-item > a {
    font-size: 0.825rem;
  }

  .index-subsection-list a {
    font-size: 0.715rem;
  }
}

@media (max-width: 760px) and (orientation: portrait) {
  :root {
    --mobile-index-height: 9rem;
  }

  .index-pane {
    display: grid;
    grid-template-rows: auto 2.625rem auto 1.125rem;
    align-content: start;
    gap: 0.2rem;
    padding-block-end: 0.125rem;
  }

  .index-pane .index-list {
    grid-row: 2;
    block-size: 100%;
  }

  .index-section-lists {
    grid-row: 3;
    block-size: 2.75rem;
    padding-block-start: 0.25rem;
  }

  .index-subsection-lists {
    grid-row: 4;
    block-size: 2.75rem;
    border-block-start: 1px solid var(--line);
    padding-block-start: 0.25rem;
  }

  /* Generated target rules enlarge the index and move the footer to row 5
     only while this row is visible. */

  .index-pane .index-list,
  .index-section-list,
  .index-subsection-lists > .index-subsection-list {
    overflow-x: auto;
    overflow-y: hidden;
    overscroll-behavior-x: contain;
    overscroll-behavior-y: auto;
    white-space: nowrap;
  }

  .index-section-list {
    flex: none;
    flex-direction: row;
  }

  .index-section-item > .index-subsection-list {
    display: none;
  }

  .index-pane .index-list a:focus-visible,
  .index-section-list a:focus-visible {
    outline-offset: -2px;
  }

  .index-footer {
    grid-row: 4;
    align-items: center;
    gap: 0.2rem;
    block-size: 100%;
    padding-block-start: 0;
    overflow: hidden;
    font-size: clamp(0.35rem, 1.5vw, 0.5rem);
  }

  .index-footer .contact-button,
  .index-footer .license-button {
    min-block-size: 1rem;
  }
}

@media (max-width: 360px) and (orientation: portrait),
       (max-height: 330px) and (orientation: landscape) {
  .index-footer p:nth-child(2),
  .index-footer p:nth-child(3) {
    display: none;
  }
}
Section index CSS4.3 KiBraw

CODE BLOCKS

Markers

A code marker is an empty one-line pre with an article-local data-src. data-lang selects highlighting; data-title, data-note, and data-open control displayed metadata and initial state. data-header="false" emits an always-visible standalone block without a summary or controls. A normal block's raw link targets the source file. Its footer repeats the title, size, raw link, and top link.

Code block examplesraw
<pre class="code-block" data-src="src/example.c" data-title="example.c" data-note="A short C example" data-lang="c"></pre>
<pre class="code-block" data-src="notes.txt" data-lang="text"></pre>
<pre class="code-block" data-src="one-liner.sh" data-lang="sh" data-footer="false"></pre>
<pre class="code-block" data-src="build.sh" data-lang="sh" data-open="false"></pre>
<pre class="code-block" data-src="output.txt" data-lang="text" data-header="false"></pre>
<pre class="code-block" data-src="output.txt" data-title="output.txt" data-lang="text" data-header="false" data-footer="true"></pre>

Display variants

A headerless block has no footer by default. Explicit data-footer="true" adds a footer with the title, size, and raw link; no navigation arrow is rendered because there is no header target.

make build

# or with defined settings

make build \
    SITE_TITLE=arachnopress \
    SITE_MARK=\
    GENERATOR_NAME=arachnopress \
    GENERATOR_VERSION=1.0.19 \
    DEFAULT_THEME=solarized-dark \
    ARTICLE_ORDER=title \
    HIGHLIGHTER=pygments
make build

# or with defined settings

make build \
    SITE_TITLE=arachnopress \
    SITE_MARK=\
    GENERATOR_NAME=arachnopress \
    GENERATOR_VERSION=1.0.19 \
    DEFAULT_THEME=solarized-dark \
    ARTICLE_ORDER=title \
    HIGHLIGHTER=pygments
Headerless with footer254 Braw

Safe rendering

Raw code is escaped before insertion. Highlighted output is used only when the selected highlighter succeeds. Otherwise escaped source is emitted.

HTML escape functionsraw
html_escape() {
	awk '{
		gsub(/&/, "\\&amp;")
		gsub(/</, "\\&lt;")
		gsub(/>/, "\\&gt;")
		print
	}' "$@"
}

html_escape_attr() {
	printf '%s\n' "$1" | awk '{
		gsub(/&/, "\\&amp;")
		gsub(/</, "\\&lt;")
		gsub(/>/, "\\&gt;")
		gsub(/"/, "\\&quot;")
		printf "%s", $0
	}'
}
Render code blockraw
render_code_block() {
	dir=$1
	src=$2
	lang=$(normalize_lang "$3")
	open_attr=$(details_open_attr "$4")
	title=$5
	note=$6
	footer=$7
	header=$8

	[ -n "$title" ] || title=$src
	if ! safe_file_path "$dir" "$src"; then
		render_missing_block "Unsafe code source path: $src" "$title" "$note" "$header"
		return
	fi
	if ! safe_lang "$lang"; then
		render_missing_block "Unsafe code language: $lang" "$title" "$note" "$header"
		return
	fi

	code_file=$dir/$src
	title_html=$(html_escape_attr "$title")
	raw_href=articles/${dir##*/}/$src
	lang_attr=
	highlight_tmp=$tmp/code.html

	[ -n "$lang" ] && lang_attr=" data-lang=\"$lang\""

	if [ ! -f "$code_file" ]; then
		render_missing_block "Missing code source: $src" "$title" "$note" "$header"
		return
	fi
	if show_header "$header"; then
		want_header=1
		if [ -z "$footer" ] &&
		    file_line_count_le "$code_file" "$code_footer_lines"; then
			footer=false
		fi
		if show_footer "$footer"; then
			want_footer=1
		else
			want_footer=0
		fi
	else
		want_header=0
		if show_explicit_footer "$footer"; then
			want_footer=1
		else
			want_footer=0
		fi
	fi

	if [ "$want_header" = 1 ]; then
		next_block_id
	fi
	if [ "$want_footer" = 1 ]; then
		size=$(format_size "$code_file")
	fi
	rm -f "$highlight_tmp"
	case $highlighter in
		none)
			raw_code_blocks=$((raw_code_blocks + 1))
			;;
		*)
			if highlight_code "$code_file" "$lang" "$highlight_tmp" 2>/dev/null &&
			   { [ ! -s "$code_file" ] || [ -s "$highlight_tmp" ]; }; then
				highlighted_blocks=$((highlighted_blocks + 1))
			else
				rm -f "$highlight_tmp"
				raw_code_blocks=$((raw_code_blocks + 1))
			fi
			;;
	esac

	if [ "$want_header" = 1 ]; then
		printf '<details class="code-details"%s>\n' "$open_attr"
		if [ "$want_footer" = 1 ]; then
			render_block_summary "$title" "$note" "$raw_href" "$block_id" "$block_id-footer"
		else
			render_block_summary "$title" "$note" "$raw_href" "$block_id" ''
		fi
		block_class=code-block
	elif [ "$want_footer" = 1 ]; then
		printf '<div class="block-standalone">\n'
		block_class=code-block
	else
		block_class='code-block code-block-standalone'
	fi
	printf '  <pre class="%s" data-src="%s" data-title="%s"%s><code>' \
		"$block_class" "$src" "$title_html" "$lang_attr"
	if [ -f "$highlight_tmp" ]; then
		cat "$highlight_tmp"
	else
		html_escape "$code_file"
	fi
	printf '</code></pre>\n'
	if [ "$want_footer" = 1 ]; then
		if [ "$want_header" = 1 ]; then
			render_block_footer "$title" "$size" "$raw_href" "$block_id" "$block_id-footer"
		else
			render_block_footer "$title" "$size" "$raw_href" '' ''
		fi
	fi
	if [ "$want_header" = 1 ]; then
		printf '</details>\n'
	elif [ "$want_footer" = 1 ]; then
		printf '</div>\n'
	fi
}
Render code block2.6 KiBraw

Without data-footer, blocks at or below CODE_FOOTER_LINES omit the footer. Explicit true or false overrides the threshold. Headerless blocks ignore the threshold and require an explicit true.

IMAGES

Markers

An image marker is an empty one-line figure with an article-local data-src. It accepts data-alt, data-caption, data-open, data-title, data-note, and data-footer. data-header="false" emits an always-visible standalone image without a summary or controls. Its footer is absent by default; explicit data-footer="true" adds the title, size, and raw link. Images are lazy-loaded and contained within the themed block. A normal block's footer also contains a top link.

Image block examplesraw
<figure class="image-block" data-src="images/diagram.png" data-title="Diagram" data-note="Article-local image path" data-alt="Build flow diagram" data-caption="Article-local image"></figure>
<figure class="image-block" data-src="images/icon.png" data-title="Icon" data-alt="Small icon" data-footer="false"></figure>
<figure class="image-block" data-src="images/plain.png" data-alt="Standalone image" data-header="false"></figure>
<figure class="image-block" data-src="images/plain.png" data-title="Standalone image" data-alt="Standalone image" data-header="false" data-footer="true"></figure>

Rendering

Render image blockraw
render_image_block() {
	dir=$1
	src=$2
	alt=$3
	caption=$4
	open_attr=$(details_open_attr "$5")
	title=$6
	note=$7
	footer=$8
	header=$9

	[ -n "$title" ] || title=$src
	if ! safe_file_path "$dir" "$src"; then
		render_missing_image "Unsafe image source path: $src" "$title" "$note" "$header"
		return
	fi

	image_file=$dir/$src
	image_href=articles/${dir##*/}/$src
	alt_html=$(html_escape_attr "$alt")
	caption_html=$(html_escape_attr "$caption")

	if [ ! -f "$image_file" ]; then
		render_missing_image "Missing image source: $src" "$title" "$note" "$header"
		return
	fi

	if show_header "$header"; then
		want_header=1
		next_block_id
		if show_footer "$footer"; then
			want_footer=1
		else
			want_footer=0
		fi
	else
		want_header=0
		if show_explicit_footer "$footer"; then
			want_footer=1
		else
			want_footer=0
		fi
	fi
	if [ "$want_footer" = 1 ]; then
		size=$(format_size "$image_file")
	fi
	if [ "$want_header" = 1 ]; then
		printf '<details class="media-details"%s>\n' "$open_attr"
		if [ "$want_footer" = 1 ]; then
			render_block_summary "$title" "$note" "$image_href" "$block_id" "$block_id-footer"
		else
			render_block_summary "$title" "$note" "$image_href" "$block_id" ''
		fi
		block_class=image-block
	elif [ "$want_footer" = 1 ]; then
		printf '<div class="block-standalone">\n'
		block_class=image-block
	else
		block_class='image-block image-block-standalone'
	fi
	printf '  <figure class="%s">\n' "$block_class"
	printf '    <img src="%s" alt="%s" loading="lazy" decoding="async">\n' "$image_href" "$alt_html"
	if [ -n "$caption" ]; then
		printf '    <figcaption>%s</figcaption>\n' "$caption_html"
	fi
	printf '  </figure>\n'
	if [ "$want_footer" = 1 ]; then
		if [ "$want_header" = 1 ]; then
			render_block_footer "$title" "$size" "$image_href" "$block_id" "$block_id-footer"
		else
			render_block_footer "$title" "$size" "$image_href" '' ''
		fi
	fi
	if [ "$want_header" = 1 ]; then
		printf '</details>\n'
	elif [ "$want_footer" = 1 ]; then
		printf '</div>\n'
	fi
}
Render image block2.0 KiBraw

DOWNLOADS

Markers

A download marker is an empty one-line div with one article-local data-src. Optional data-title and data-note change displayed text, not the target. The rendered link shows the raw file size and a SHA256 checksum when a supported local checksum tool is available.

Download block examplesraw
<div class="article-downloads" data-src="build.sh" data-note="The main build script"></div>
<div class="article-downloads" data-src="Makefile" data-note="Build settings"></div>
<div class="article-downloads" data-src="styles.css" data-note="Shared stylesheet"></div>

Rendering

Render download blockraw
render_download() {
	dir=$1
	src=$2
	title=$3
	note=$4

	[ -n "$title" ] || title=$src
	title_html=$(html_escape_attr "$title")
	note_html=$(html_escape_attr "$note")
	href=articles/${dir##*/}/$src
	file=$dir/$src
	printf '<div class="article-downloads">\n'
	if safe_file_path "$dir" "$src" && [ -f "$file" ]; then
		size=$(format_size "$file")
		checksum=$(file_sha256 "$file" || :)
		printf '  <div class="article-download">'
		printf '<a class="article-download-hit" href="%s" download aria-label="Download %s"></a>' \
			"$href" "$title_html"
		printf '<span class="article-download-kind">Download</span>'
		render_block_label "$title" "$note"
		printf '<span class="block-size">%s</span>' "$size"
		if [ -n "$checksum" ]; then
			printf '<span class="block-checksum">SHA256 (%s) = %s</span>' \
				"$src" "$checksum"
		fi
		printf '</div>\n'
	else
		printf '  <span class="article-download article-download-missing">'
		printf '<span class="article-download-kind">Missing</span>'
		printf '<span class="block-label"><code class="block-title">%s</code>' "$title_html"
		if [ -n "$note" ]; then
			printf '<span class="block-note">%s</span>' "$note_html"
		fi
		printf '</span></span>\n'
	fi
	printf '</div>\n'
}
Render download block1.2 KiBraw

SYNTAX HIGHLIGHTING

Pygments is the default highlighter; Source-highlight and no highlighting are also supported. With Pygments, an omitted language uses filename inference and data-lang="auto" may also guess from content. An explicit language requests that lexer. Missing tools and failed highlighting fall back to escaped source. Successful highlighted output is filtered to unwrap whitespace-only Pygments and Source-highlight spans, preserving their whitespace while reducing the generated DOM.

Syntax highlightingraw
optimize_highlighted_html() {
	sed \
		-e 's/<span class="w">\([[:space:]]*\)<\/span>/\1/g' \
		-e 's/<span class="sh-normal">\([[:space:]]*\)<\/span>/\1/g' \
		"$1"
}

highlight_code() {
	code_file=$1
	lang=$2
	highlight_output=$3
	unoptimized_file=$tmp/code-unoptimized.html

	[ "$lang" != text ] || return 1
	rm -f "$unoptimized_file"

	case $highlighter in
		pygments)
			highlight_pygments "$code_file" "$lang" "$unoptimized_file" || return 1
			;;
		source-highlight)
			highlight_source "$code_file" "$lang" "$unoptimized_file" || return 1
			;;
		none) return 1 ;;
	esac

	optimize_highlighted_html "$unoptimized_file" > "$highlight_output" || return 1
}

highlight_pygments() {
	code_file=$1
	lang=$2
	output_file=$3

	[ -n "$pygmentize" ] || return 1

	case $lang in
		'')
			"$pygmentize" -f html -O nowrap "$code_file" > "$output_file"
			;;
		auto)
			auto_lexer=$("$pygmentize" -N "$code_file" 2>/dev/null || printf text)
			case $auto_lexer in
				''|text)
					"$pygmentize" -f html -O nowrap -g "$code_file" > "$output_file"
					;;
				*)
					if safe_lang "$auto_lexer"; then
						"$pygmentize" -f html -O nowrap -l "$auto_lexer" "$code_file" > "$output_file"
					else
						"$pygmentize" -f html -O nowrap -g "$code_file" > "$output_file"
					fi
					;;
			esac
			;;
		*)
			"$pygmentize" -f html -O nowrap -l "$lang" "$code_file" > "$output_file"
			;;
	esac
}

highlight_source() {
	code_file=$1
	lang=$2
	output_file=$3

	[ -n "$source_highlight" ] && [ -f "$outlang" ] || return 1

	case $lang in
		''|auto)
			"$source_highlight" --no-doc --outlang-def="$outlang" \
				--input="$code_file" --output=STDOUT > "$output_file"
			;;
		make)
			"$source_highlight" --no-doc --outlang-def="$outlang" \
				--src-lang=makefile \
				--input="$code_file" --output=STDOUT > "$output_file"
			;;
		*)
			"$source_highlight" --no-doc --outlang-def="$outlang" \
				--src-lang="$lang" \
				--input="$code_file" --output=STDOUT > "$output_file"
			;;
	esac
}
Syntax highlighting1.9 KiBraw

Source-highlight uses tools/html-fragment.outlang to emit an HTML fragment rather than a complete document.

THEMES

Theme selection uses HTML radio inputs and CSS only. The build copies tools/theme-menu.html, marks the configured default, and uses :has() to select theme variables. The checked theme is highlighted using the active theme palette.

Theme CSSraw
body:has(#theme-solarized-light:checked),
body:has(#theme-selenized-light:checked),
body:has(#theme-selenized-white:checked),
body:has(#theme-oksolar-light:checked),
body:has(#theme-gruvbox-light:checked),
body:has(#theme-tomorrow:checked),
body:has(#theme-everforest-light:checked),
body:has(#theme-ayu-light:checked),
body:has(#theme-catppuccin-latte:checked),
body:has(#theme-rose-pine-dawn:checked) {
  color-scheme: light;

  --page-bg: var(--base3);
  --panel-bg: var(--base2);
  --text: var(--base00);
  --strong: var(--base03);
  --muted: var(--base01);
  --line: var(--base1);
  --code-bg: var(--base3);
  --code-text: var(--base00);
  --code-border: var(--base1);
  --code-title-bg: var(--base2);
  --code-title-text: var(--base01);
  --download-hover-border: var(--muted);
}

.theme-list input:checked + label {
  border-color: var(--index-selected-border);
  background: var(--index-selected-bg);
  color: var(--index-selected-text);
}

LAYOUT

Responsive layout

The page has a sticky header, an article index, and an article pane. The first article is shown by default; URL fragments select other articles and sections.

Layout CSSraw
.site-header {
  position: sticky;
  top: 0;
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: space-between;
  block-size: var(--header-height);
  border-block-end: 1px solid var(--line);
  background: var(--panel-bg);
}

.shell {
  display: grid;
  grid-template-columns: 16rem minmax(0, 1fr);
  min-block-size: calc(100dvh - var(--header-height));
}

.index-pane {
  position: sticky;
  top: var(--header-height);
  display: flex;
  flex-direction: column;
  block-size: calc(100dvh - var(--header-height));
  overflow: hidden;
  border-inline-end: 1px solid var(--line);
  background: var(--panel-bg);
  padding: 1rem;
}

@media (max-width: 760px) and (orientation: portrait) {
  .shell {
    grid-template-columns: 1fr;
  }

  .index-pane {
    block-size: var(--mobile-index-height);
    border-block-end: 1px solid var(--line);
    border-inline-end: 0;
  }
}

@media (max-width: 960px) and (orientation: landscape) {
  .shell {
    grid-template-columns: 12rem minmax(0, 1fr);
  }

  .index-pane {
    block-size: calc(100dvh - var(--header-height));
  }

  .article-pane:has(.article:target) .article:target,
  .article-pane:has(.article :target) .article:has(:target),
  .article[id],
  .article h2[id],
  .article h3[id],
  .code-details summary[id],
  .media-details summary[id],
  .block-footer[id] {
    scroll-margin-top: calc(var(--header-height) + 1rem);
  }
}

@media (orientation: landscape) {
  .index-pane .index-list {
    flex: 1 1 0;
    max-block-size: max-content;
  }

  .index-pane .index-list:has(> .index-item:nth-child(2)) {
    min-block-size: 4.375rem;
  }

  .index-section-lists {
    flex: 2 1 0;
    min-block-size: 4.25rem;
  }
}

@media (min-width: 961px) and (orientation: landscape) {
  .index-pane .index-list:has(> .index-item:nth-child(2)) {
    min-block-size: 5.625rem;
  }
}

@media (max-height: 480px) and (orientation: landscape) {
  .index-section-lists {
    margin-block-start: 0.4rem;
    padding-block-start: 0.4rem;
  }
}

@media (max-width: 360px) and (orientation: portrait),
       (max-height: 330px) and (orientation: landscape) {
  .index-footer p:nth-child(2),
  .index-footer p:nth-child(3) {
    display: none;
  }
}

Wide layouts use a side index with subsections nested below the selected section. Narrow portrait layouts use horizontal article and section rows, adding a third subsection row only when the selected section has one. All layouts use document scrolling. Article content has a bounded readable width, while section headings retain a wider manual-page span. Fixed portrait navigation tracks reserve scrollbar space, while the metadata row is sized to fit without one. At portrait widths of 360px or less, and landscape heights of 330px or less, the descriptive and build-date entries are hidden while the generator identity, Contact control, and license remain. Coarse-pointer controls retain touch-sized targets. Landscape side indexes reserve room for two complete items in each main list, then give the article and section lists approximately one third and two thirds of the remaining space. A short article list stays content-sized so its unused share passes to the section list.

Article selection

Article selectionraw
.article {
  display: none;
  max-inline-size: 96rem;
  margin-inline: auto;
}

.article-header,
.article > .article-downloads,
.article > section > * {
  max-inline-size: 88ch;
  margin-inline: auto;
}

.article:first-of-type {
  display: block;
}

.article-pane:has(.article:target) .article {
  display: none;
}

.article-pane:has(.article:target) .article:target {
  display: block;
  scroll-margin-top: calc(var(--header-height) + 1rem);
}

STYLING

Block styling

Code, images, downloads, popovers, indexes, and highlighting classes use shared theme variables in styles.css. Syntax colours retain each palette hue while mixing toward a readable foreground; Pygments token subclasses keep distinct semantic roles.

Code CSSraw
.code-details,
.media-details {
  margin: 1rem 0 1.35rem;
}

.code-details summary,
.media-details summary {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  cursor: pointer;
  border: 1px solid var(--code-border);
  border-block-end: 0;
  border-radius: 6px 6px 0 0;
  background: var(--code-title-bg);
  color: var(--code-title-text);
  padding: 0.45rem 0.7rem;
}

.block-note {
  color: var(--base01);
  font: 0.76rem/1.3 var(--font-ui);
  font-size: clamp(0.76rem, calc(0.7rem + 0.1vw), 0.8rem);
}

.block-action {
  display: inline-flex;
  align-items: center;
  color: var(--blue);
  font: 0.78rem/1.3 var(--font-ui);
  line-height: 1;
  text-decoration: none;
}

.block-action:hover {
  color: var(--strong);
}

@media (max-width: 960px), (pointer: coarse) {
  .block-action {
    justify-content: center;
    min-block-size: 1.5rem;
    padding-inline: 0.2rem;
  }

  .block-jump {
    min-inline-size: 1.5rem;
    font-size: 0.82rem;
  }
}

@media (max-width: 760px) {
  .block-action {
    font-size: 0.68rem;
  }

  .block-jump {
    font-size: 0.82rem;
  }
}

.article-download {
  position: relative;
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: 0.2rem 0.6rem;
}

.article-download:hover {
  border-color: var(--download-hover-border);
  color: var(--strong);
}

.article-download-hit {
  position: absolute;
  z-index: 1;
  inset: 0;
  border-radius: inherit;
}

.article-download-kind {
  position: relative;
  z-index: 2;
  grid-row: 1 / 3;
  grid-column: 1;
  display: inline-flex;
  align-items: center;
  gap: 0.6rem;
  pointer-events: none;
}

.article-download:not(.article-download-missing) .block-label {
  position: relative;
  z-index: 2;
  grid-row: 1;
  grid-column: 2;
  min-inline-size: 0;
  pointer-events: none;
}

.article-download .block-checksum {
  position: relative;
  z-index: 2;
  grid-row: 2;
  grid-column: 2;
  justify-self: stretch;
  pointer-events: auto;
  text-align: start;
  user-select: text;
}

.article-download:not(.article-download-missing) .block-size {
  position: relative;
  z-index: 2;
  grid-row: 1 / 3;
  grid-column: 3;
  align-self: center;
  pointer-events: none;
}

.code-details summary:focus-visible,
.media-details summary:focus-visible {
  color: var(--strong);
  outline: 2px solid var(--strong);
  outline-offset: -2px;
}

.code-details summary:target,
.media-details summary:target,
.block-footer:target {
  background: var(--code-title-bg);
  color: var(--code-title-text);
  outline: 1px solid var(--line);
  outline-offset: -1px;
  animation: block-target-outline 1.2s ease-out forwards;
}

@keyframes block-target-outline {
  to {
    outline-color: transparent;
  }
}

.block-action:focus-visible {
  background: var(--line);
  color: var(--strong);
  outline: 2px solid var(--strong);
  outline-offset: 2px;
}

.code-download {
  margin-inline-start: auto;
}

.block-size {
  display: inline-flex;
  align-items: center;
  margin-inline-start: auto;
  color: var(--base01);
  font: 0.72rem/1.25 var(--font-mono);
  font-variant-numeric: tabular-nums;
  line-height: 1;
  white-space: nowrap;
}

.block-checksum {
  display: block;
  min-inline-size: 0;
  color: var(--base01);
  font: 0.52rem/1.2 var(--font-mono);
  font-size: clamp(0.52rem, calc(0.4rem + 0.2vw), 0.64rem);
  font-variant-numeric: tabular-nums;
  overflow: hidden;
  text-align: end;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.code-details:not([open]) summary .block-jump,
.media-details:not([open]) summary .block-jump {
  display: none;
}

.code-block {
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
  min-inline-size: 0;
  max-inline-size: 100%;
  overflow: hidden;
  margin: 0;
  border: 1px solid var(--code-border);
  border-radius: 0;
  background: var(--code-bg);
  color: var(--syntax-plain);
}

.code-block-standalone,
.image-block-standalone {
  margin-block: 1rem 1.35rem;
  border-radius: 6px;
}

.block-standalone {
  min-inline-size: 0;
  margin: 1rem 0 1.35rem;
}

.block-standalone .code-block,
.block-standalone .image-block {
  border-radius: 6px 6px 0 0;
}

.code-details:not(:has(.block-footer)) .code-block {
  border-radius: 0 0 6px 6px;
}

.code-block code {
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
  display: block;
  min-inline-size: 0;
  overflow: auto;
  max-inline-size: 100%;
  padding: 1rem;
  font: 0.92rem/1.55 var(--font-mono);
  tab-size: 2;
  white-space: pre;
}

.code-block code * {
  font-size: inherit;
}

.block-footer {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  border: 1px solid var(--code-border);
  border-block-start: 0;
  border-radius: 0 0 6px 6px;
  background: var(--code-title-bg);
  color: var(--code-title-text);
  padding: 0.4rem 0.7rem;
}

.block-footer .block-label {
  color: var(--base01);
}

Syntax colours

Highlight CSSraw
.sh-comment,
.code-block .c,
.code-block .c1,
.code-block .cm {
  color: var(--syntax-muted);
  font-style: italic;
}

.sh-keyword,
.code-block .k {
  color: var(--syntax-green);
  font-weight: 700;
}

.sh-type,
.code-block .kt,
.code-block .nc,
.code-block .nn {
  color: var(--syntax-violet);
}

.sh-preproc,
.code-block .cp,
.code-block .nd {
  color: var(--syntax-magenta);
}

.sh-string,
.code-block .s,
.code-block .s1,
.code-block .s2 {
  color: var(--syntax-cyan);
}

.sh-function,
.code-block .nf {
  color: var(--syntax-blue);
}

.sh-number,
.code-block .m,
.code-block .mi {
  color: var(--syntax-orange);
}

.sh-variable,
.code-block .na,
.code-block .nv {
  color: var(--syntax-yellow);
}

.code-block .o,
.code-block .p {
  color: var(--syntax-muted);
}

.sh-error,
.code-block .err {
  color: var(--syntax-red);
}

CONTACT

The metadata Contact control opens a native HTML popover containing an optional reply address and a required message of at most 500 characters. The zero-JS form adds a fixed <sanitised-generator-name>_contact=1 query item before submitting a GET request to the static site root. For the default name, this is arachnopress_contact=1, making contact requests easy to identify without coupling the marker to the generator version. Its fragment targets a CSS confirmation panel after the new page request. HTTPS encrypts the request in transit, but the values remain in the URL and server log, so the form warns against confidential information.

LICENSE

The license popover is rendered from a plain text template. The build replaces <YEAR> and <COPYRIGHT_HOLDER>; the latter defaults to username@hostname.

License functionraw
license_escape() {
	env ARACHNOPRESS_COPYRIGHT_HOLDER="$copyright_holder" \
	awk -v year="$copyright_year" '
		function replace(s, needle, value,   p, out) {
			out = ""
			while (p = index(s, needle)) {
				out = out substr(s, 1, p - 1) value
				s = substr(s, p + length(needle))
			}
			return out s
		}
		BEGIN {
			holder = ENVIRON["ARACHNOPRESS_COPYRIGHT_HOLDER"]
		}
		{
			line = replace($0, "<YEAR>", year)
			line = replace(line, "<COPYRIGHT_HOLDER>", holder)
			gsub(/&/, "\\&amp;", line)
			gsub(/</, "\\&lt;", line)
			gsub(/>/, "\\&gt;", line)
			print line
		}
	' "$license_file"
}

OUTPUT

The build completes temporary index.html and nav-sections.css files before publishing either output. The page links styles.css and embeds the favicon as a data URL.

Page outputraw
nav_tmp=$(mktemp "$root/.nav-sections.css.XXXXXXXXXX")
write_section_nav_css > "$nav_tmp"

asset_version=$(
	{
		cksum < "$styles_file"
		cksum < "$nav_tmp"
	} | cksum | awk '{ print $1 }'
)
asset_version_html=$(html_escape_attr "$asset_version")
output_tmp=$(mktemp "$root/.index.html.XXXXXXXXXX")

{
	write_page_start
	write_nav_list title "$order_title_file"
	write_nav_list created "$order_created_file"
	write_nav_list modified "$order_modified_file"
	write_section_lists "$order_file"
	cat <<EOF
        <footer class="index-footer">
          <p>$generator_label_html</p>
          <p>zero-JS, cookie-free static site</p>
          <p>Built $build_date_html</p>
          <p class="index-footer-actions">
            <button class="contact-button" type="button" popovertarget="contact-popover">Contact</button>
            <button class="license-button" type="button" popovertarget="license-popover">$license_title_html</button>
          </p>
        </footer>
EOF
	write_contact_popover
	write_license_popover
	cat <<EOF
      </nav>

      <section class="article-pane" aria-label="Articles">
EOF
	cat "$articles_file"
	cat <<EOF
      </section>
    </main>
EOF
	write_contact_confirmation
	cat <<EOF
  </body>
</html>
EOF
} > "$output_tmp"

chmod 0644 "$nav_tmp" "$output_tmp"
mv "$nav_tmp" "$root/nav-sections.css"
nav_tmp=
mv "$output_tmp" "$root/index.html"
output_tmp=

SERVING

The generated files are static and can be served without any server-side scripting, or opened locally with file://. Minimal OpenBSD acme-client(1) and httpd(8) examples follow.

Acme client

/etc/acme-client.confacme client configuration exampleraw
authority letsencrypt {
        api url "https://acme-v02.api.letsencrypt.org/directory"
        account key "/etc/acme/letsencrypt-privkey.pem"
}

domain arachnogoat.com {
        domain key "/etc/ssl/private/arachnogoat.com.key"
        domain full chain certificate "/etc/ssl/arachnogoat.com.fullchain.pem"
        sign with letsencrypt
}

Httpd

/etc/httpd.confOpenBSD httpd(8) conf exampleraw
server "arachnogoat.com" {
	listen on * port 80

	location "/.well-known/acme-challenge/*" {
		root "/acme"
		request strip 2
	}

	location * {
		block return 301 "https://$HTTP_HOST$REQUEST_URI"
	}
}

server "arachnogoat.com" {
	listen on * tls port 443

	tls {
		certificate "/etc/ssl/arachnogoat.com.fullchain.pem"
		key "/etc/ssl/private/arachnogoat.com.key"
	}

	root "/htdocs/arachnogoat.com"
}

Message sent

X

Thank you. Your message has been sent.