Project Notes

arachnopress

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

Downloadarachnopress_1.0.19.tar.gzgzipped source archive111 KiBSHA256 (arachnopress_1.0.19.tar.gz) = b3cf232cf036e4bb19c25d2362d75a180db94726710907032534a751e3207ab7

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"
}

Patch Notes

bsdsshd.rd

OpenBSD/amd64 installer ramdisk and miniroot targets with sshd access.

Downloadbsdsshd.rd.patchApplies to /usr/src39 KiBSHA256 (bsdsshd.rd.patch) = 3fce0bf5a4a0ef3fad069b066655b3d24fdd56e0e496173e77734970ec51b917

DESCRIPTION

The patch adds separate bsdsshd.rd and minirootXX_sshd.img targets. The ramdisk configures networking from /auto_install.conf, starts sshd, and leaves the installer environment available to the remote root user.

The default retains the local installer menu and shell. RDSSHD_CONSOLE_LOCK=yes suppresses the local userland session. Boot-loader and kernel console output remain visible.

This target supports remote storage preparation and installation, including softraid(4) work with bioctl(8). Use autoinstall(8), install.site(5), and siteXX.tgz for fully unattended installation.

The patch adds files only. Stock source files, Makefiles, and installer targets are unchanged. Enhanced targets are available only through Makefile.rdsshd.

PATCH

bsdsshd.rd.patchraw
--- /dev/null
+++ b/distrib/amd64/ramdisk_cd/rdsshd/Makefile.rdsshd
@@ -0,0 +1,411 @@
+#	$OpenBSD$
+
+.include <bsd.own.mk>
+
+RDSSHD_AUTHORIZED_KEYS?=
+RDSSHD_HOST_KEY?=
+RDSSHD_CONSOLE_LOCK?=	no
+RDSSHD_KERNEL_STACK_PROTECTOR?=	yes
+RDSSHD_PORT?=	22
+RDSSHD_AI_IF?=	em0
+RDSSHD_AI_HOSTNAME?=	rdinstall
+RDSSHD_AI_IPV4?=	autoconf
+RDSSHD_AI_NETMASK?=	255.255.255.0
+RDSSHD_AI_ROUTE?=	none
+RDSSHD_AI_IPV6?=	none
+RDSSHD_AI_DOMAIN?=	my.domain
+RDSSHD_AI_DNS?=	none
+RDSSHD_FSSIZE?=	32768
+
+RDSSHD_BASEDIR=	${.CURDIR}/..
+RDSSHD_TOP=	${.CURDIR}/../../../..
+RDSSHD_UTILS=	${RDSSHD_BASEDIR}/../../miniroot
+RDSSHD_MTREE=	${RDSSHD_UTILS}/mtree.conf
+RDSSHD_EFIBOOT=	${DESTDIR}/usr/mdec/BOOTX64.EFI \
+		${DESTDIR}/usr/mdec/BOOTIA32.EFI
+RDSSHD_MOUNT_ARGS_MSDOS=	-o-s
+
+RDSSHD_RAMDISK=	RAMDISK_CD_SSHD
+RDSSHD_KERNEL=	${.OBJDIR}/bsd
+.if ${RDSSHD_KERNEL_STACK_PROTECTOR:L} == "no"
+RDSSHD_RAMDISK=	RAMDISK_CD_SSHD_NO_PROPOLICE
+RDSSHD_KERNEL=	${.OBJDIR}/bsd.no-propolice
+.endif
+RDSSHD_FS=	miniroot${OSrev}_sshd.img
+RDSSHD_BUILDOBJDIR=	${.OBJDIR}/build
+RDSSHD_KERNELOBJDIR=	${RDSSHD_BUILDOBJDIR}/kernel/${RDSSHD_RAMDISK}
+RDSSHD_INSTBIN=	${RDSSHD_BUILDOBJDIR}/instbin
+RDSSHD_STAGE=	${.OBJDIR}/stage
+RDSSHD_RDOBJDIR=	${.OBJDIR}/rdobj
+RDSSHD_SSHOBJDIR=	${.OBJDIR}/sshobj
+RDSSHD_INITOBJDIR=	${.OBJDIR}/initobj
+RDSSHD_INITSRCDIR=	${.OBJDIR}/initsrc
+RDSSHD_MOUNT_POINT=	${.OBJDIR}/mnt
+RDSSHD_VND=	${.OBJDIR}/vnd
+RDSSHD_BOOT=	${.OBJDIR}/boot
+RDSSHD_OBJCHECK=	${.OBJDIR}/.rdsshd-obj-ok
+RDSSHD_MAKEFILE=	${.CURDIR}/Makefile.rdsshd
+RDSSHD_INITMAKEFILE=	${.CURDIR}/Makefile.init
+RDSSHD_KERNELCONF=	${.CURDIR}/${RDSSHD_RAMDISK}
+RDSSHD_BASECONF=	${RDSSHD_TOP}/sys/arch/${MACHINE}/conf/RAMDISK_CD
+RDSSHD_KERNELCONFDEPS=	${RDSSHD_BASECONF}
+RDSSHD_LISTS=	${RDSSHD_BASEDIR}/list ${.CURDIR}/list.sshd \
+		${RDSSHD_STAGE}/list.console
+RDSSHD_INITSRC=	${RDSSHD_INITSRCDIR}/init.c
+RDSSHD_INIT=	${RDSSHD_STAGE}/init
+RDSSHD_INSTALLERPATCH=	${.CURDIR}/installer.sshd.patch
+RDSSHD_CONSOLEPATCH=	${.CURDIR}/console-lock.sshd.patch
+RDSSHD_INITPATCH=	${.CURDIR}/init.sshd.patch
+RDSSHD_SSHDCONF=	${.CURDIR}/sshd_config
+RDSSHD_MRMAKEFSARGS?=	-s 20m \
+		-o rdroot,minfree=0,bsize=4096,fsize=512,density=4096
+RDSSHD_BINS=	${RDSSHD_STAGE}/bin/sshd \
+		${RDSSHD_STAGE}/bin/ssh-keygen \
+		${RDSSHD_STAGE}/bin/sshd-session \
+		${RDSSHD_STAGE}/bin/sshd-auth
+
+RDSSHD_INITDEP=
+.if ${RDSSHD_CONSOLE_LOCK:L} == "yes"
+RDSSHD_INITDEP=	${RDSSHD_INIT}
+.endif
+
+.PHONY: rdsshd
+rdsshd: bsdsshd.rd ${RDSSHD_FS}
+
+.PHONY: rdsshd-objcheck
+rdsshd-objcheck: ${RDSSHD_OBJCHECK}
+
+${RDSSHD_OBJCHECK}:
+	@if [ "${.OBJDIR}" = "${.CURDIR}" ]; then \
+		echo "private object directory is not active;" >&2; \
+		echo "run 'make -f Makefile.rdsshd obj' separately first" >&2; \
+		exit 1; \
+	fi
+	touch $@
+
+${RDSSHD_KERNEL}: ${RDSSHD_OBJCHECK} ${RDSSHD_MAKEFILE} \
+	    ${RDSSHD_KERNELCONF} ${RDSSHD_KERNELCONFDEPS}
+	install -d -o ${BUILDUSER} -g ${WOBJGROUP} ${RDSSHD_KERNELOBJDIR}
+	su ${BUILDUSER} -c \
+	    'config -b ${RDSSHD_KERNELOBJDIR} -s ${RDSSHD_TOP}/sys \
+	    ${RDSSHD_KERNELCONF} && cd ${RDSSHD_KERNELOBJDIR} && \
+	    MAKEOBJDIR=${RDSSHD_KERNELOBJDIR} ${MAKE} clean && \
+	    exec env MAKEOBJDIR=${RDSSHD_KERNELOBJDIR} ${MAKE} ${MFLAGS}'
+	cp -p ${RDSSHD_KERNELOBJDIR}/bsd $@
+
+bsdsshd.gz: bsdsshd.rd
+	objcopy -g -x -R .comment -R .SUNW_ctf \
+	    -K rd_root_size -K rd_root_image \
+	    bsdsshd.rd bsdsshd.strip
+	gzip -9cn bsdsshd.strip > bsdsshd.gz
+.if !empty(RDSSHD_HOST_KEY)
+	chmod 600 bsdsshd.strip $@
+.endif
+
+.PHONY: rdsshd-stock-deps rdsshd-instbin
+rdsshd-stock-deps: ${RDSSHD_OBJCHECK} ${RDSSHD_MAKEFILE}
+	@_objroot=`cd ${RDSSHD_BASEDIR} && ${MAKE} -V BSDOBJDIR`; \
+	if [ ! -d "$$_objroot" ]; then \
+		echo "normal OpenBSD object root does not exist: $$_objroot" >&2; \
+		echo "create it before building rdsshd" >&2; \
+		exit 1; \
+	fi
+	cd ${RDSSHD_TOP}/lib && ${MAKE} obj
+	cd ${RDSSHD_TOP}/distrib/special && ${MAKE} obj
+	cd ${RDSSHD_BASEDIR} && ${MAKE} obj
+	@_srcdir=`cd ${RDSSHD_BASEDIR} && pwd`; \
+	_objdir=`cd ${RDSSHD_BASEDIR} && ${MAKE} -V .OBJDIR`; \
+	if [ "$$_objdir" = "$$_srcdir" ] || [ ! -d "$$_objdir" ]; then \
+		echo "normal ramdisk_cd object directory is not active" >&2; \
+		exit 1; \
+	fi
+	cd ${RDSSHD_TOP}/distrib/special/libstubs && ${MAKE} ${MFLAGS}
+
+rdsshd-instbin: rdsshd-stock-deps
+	cd ${RDSSHD_BASEDIR} && ${MAKE} ${MFLAGS} instbin
+	install -d ${RDSSHD_BUILDOBJDIR}
+	@_objdir=`cd ${RDSSHD_BASEDIR} && ${MAKE} -V .OBJDIR`; \
+	if [ ! -f "$$_objdir/instbin" ]; then \
+		echo "stock ramdisk_cd instbin was not built" >&2; \
+		exit 1; \
+	fi; \
+	cp -p "$$_objdir/instbin" ${RDSSHD_INSTBIN}
+
+bsdsshd.rd: rdsshd-files ${RDSSHD_INITDEP} rdsshd-instbin \
+	    ${RDSSHD_KERNEL}
+	install -d ${RDSSHD_RDOBJDIR}
+	rm -f ${RDSSHD_RDOBJDIR}/instbin \
+	    ${RDSSHD_RDOBJDIR}/mr.fs \
+	    ${RDSSHD_RDOBJDIR}/bsd.rd
+	cp -p ${RDSSHD_INSTBIN} ${RDSSHD_RDOBJDIR}/instbin
+	rm -rf ${RDSSHD_RDOBJDIR}/mr.fs.d
+	install -d -o root -g wheel ${RDSSHD_RDOBJDIR}/mr.fs.d
+	mtree -def ${RDSSHD_MTREE} -p ${RDSSHD_RDOBJDIR}/mr.fs.d -u
+	CURDIR=${RDSSHD_BASEDIR} OBJDIR=${RDSSHD_RDOBJDIR} OSrev=${OSrev} \
+	    TARGDIR=${RDSSHD_RDOBJDIR}/mr.fs.d UTILS=${RDSSHD_UTILS} \
+	    RELEASEDIR=${RELEASEDIR} sh ${RDSSHD_UTILS}/runlist.sh \
+	    ${RDSSHD_LISTS}
+	rm ${RDSSHD_RDOBJDIR}/mr.fs.d/instbin
+	makefs ${RDSSHD_MRMAKEFSARGS} ${RDSSHD_RDOBJDIR}/mr.fs \
+	    ${RDSSHD_RDOBJDIR}/mr.fs.d
+	cp -p ${RDSSHD_KERNEL} ${RDSSHD_RDOBJDIR}/bsd.rd
+	rdsetroot ${RDSSHD_RDOBJDIR}/bsd.rd ${RDSSHD_RDOBJDIR}/mr.fs
+	cp ${RDSSHD_RDOBJDIR}/bsd.rd $@
+.if !empty(RDSSHD_HOST_KEY)
+	chmod 600 ${RDSSHD_RDOBJDIR}/mr.fs \
+	    ${RDSSHD_RDOBJDIR}/bsd.rd $@
+.endif
+
+${RDSSHD_FS}: bsdsshd.gz
+	-umount -f ${RDSSHD_MOUNT_POINT} >/dev/null 2>&1
+	@if [ -e ${RDSSHD_VND} ] && [ ! -s ${RDSSHD_VND} ]; then \
+		rm -f ${RDSSHD_VND}; \
+	fi
+	@if [ -e ${RDSSHD_VND} ]; then \
+		echo "stale private vnd state; run" \
+		    "'make -f Makefile.rdsshd unconfig-rdsshd' first" >&2; \
+		exit 1; \
+	fi
+	install -d ${RDSSHD_MOUNT_POINT}
+	dd if=/dev/zero of=${RDSSHD_FS} bs=512 count=${RDSSHD_FSSIZE}
+	vnconfig -v ${.OBJDIR}/${RDSSHD_FS} > ${RDSSHD_VND}
+	fdisk -yi -l ${RDSSHD_FSSIZE} -b 960 -f ${DESTDIR}/usr/mdec/mbr \
+	    `cat ${RDSSHD_VND}`
+	echo '/ *' | disklabel -wAT- `cat ${RDSSHD_VND}`
+	newfs -t msdos /dev/r`cat ${RDSSHD_VND}`i
+	mount ${RDSSHD_MOUNT_ARGS_MSDOS} /dev/`cat ${RDSSHD_VND}`i \
+	    ${RDSSHD_MOUNT_POINT}
+	mkdir -p ${RDSSHD_MOUNT_POINT}/efi/boot
+	cp ${RDSSHD_EFIBOOT} ${RDSSHD_MOUNT_POINT}/efi/boot
+	umount ${RDSSHD_MOUNT_POINT}
+	newfs -O 1 -m 0 -o space -i 524288 -c ${RDSSHD_FSSIZE} \
+	    /dev/r`cat ${RDSSHD_VND}`a
+	mount /dev/`cat ${RDSSHD_VND}`a ${RDSSHD_MOUNT_POINT}
+	objcopy -S -R .comment ${DESTDIR}/usr/mdec/boot ${RDSSHD_BOOT}
+	installboot -v -r ${RDSSHD_MOUNT_POINT} `cat ${RDSSHD_VND}` \
+	    ${DESTDIR}/usr/mdec/biosboot ${RDSSHD_BOOT}
+	install -c -m 555 -o root -g wheel bsdsshd.gz \
+	    ${RDSSHD_MOUNT_POINT}/bsd
+	df -i ${RDSSHD_MOUNT_POINT}
+	umount ${RDSSHD_MOUNT_POINT}
+	vnconfig -u `cat ${RDSSHD_VND}`
+	rm -f ${RDSSHD_VND}
+.if !empty(RDSSHD_HOST_KEY)
+	chmod 600 $@
+.endif
+
+.PHONY: rdsshd-check rdsshd-files rdsshd-scripts
+rdsshd-check:
+	@if [ -z "${RDSSHD_AUTHORIZED_KEYS}" ]; then \
+		echo "set RDSSHD_AUTHORIZED_KEYS to a public key file" >&2; \
+		exit 1; \
+	fi
+	@case "${RDSSHD_CONSOLE_LOCK:L}" in \
+	yes|no) ;; \
+	*) echo "RDSSHD_CONSOLE_LOCK must be yes or no" >&2; exit 1;; \
+	esac
+	@case "${RDSSHD_KERNEL_STACK_PROTECTOR:L}" in \
+	yes|no) ;; \
+	*) echo "RDSSHD_KERNEL_STACK_PROTECTOR must be yes or no" >&2; exit 1;; \
+	esac
+
+${RDSSHD_INITSRC}: ${RDSSHD_OBJCHECK} ${RDSSHD_TOP}/sbin/init/init.c \
+	    ${RDSSHD_INITPATCH}
+	install -d ${RDSSHD_INITSRCDIR}
+	rm -f ${RDSSHD_INITSRC}.tmp ${RDSSHD_INITSRC}.tmp.orig \
+	    ${RDSSHD_INITSRC}.tmp.rej
+	cp ${RDSSHD_TOP}/sbin/init/init.c ${RDSSHD_INITSRC}.tmp
+	cd ${RDSSHD_INITSRCDIR} && \
+	    patch -f -s -p0 -F0 < ${RDSSHD_INITPATCH}
+	mv ${RDSSHD_INITSRC}.tmp ${RDSSHD_INITSRC}
+
+${RDSSHD_INIT}: ${RDSSHD_INITSRC} \
+	    ${RDSSHD_TOP}/sbin/init/pathnames.h ${RDSSHD_INITMAKEFILE} \
+	    ${RDSSHD_BASEDIR}/../../special/init/Makefile \
+	    ${RDSSHD_BASEDIR}/../../special/Makefile.inc
+	install -d ${RDSSHD_INITOBJDIR} ${RDSSHD_STAGE}
+	cd ${RDSSHD_BASEDIR}/../../special/init && \
+	    MAKEOBJDIR=${RDSSHD_INITOBJDIR} ${MAKE} ${MFLAGS} \
+	    -f ${RDSSHD_INITMAKEFILE} \
+	    RDSSHD_INITSRCDIR=${RDSSHD_INITSRCDIR} init
+	install -c -s ${RDSSHD_INITOBJDIR}/init $@
+
+rdsshd-scripts: ${RDSSHD_OBJCHECK} ${RDSSHD_UTILS}/install.sub \
+	    ${RDSSHD_UTILS}/dot.profile \
+	    ${RDSSHD_INSTALLERPATCH} ${RDSSHD_CONSOLEPATCH}
+	install -d ${RDSSHD_STAGE}
+	rm -f ${RDSSHD_STAGE}/install.sub.tmp \
+	    ${RDSSHD_STAGE}/install.sub.tmp.orig \
+	    ${RDSSHD_STAGE}/install.sub.tmp.rej \
+	    ${RDSSHD_STAGE}/dot.profile.tmp \
+	    ${RDSSHD_STAGE}/dot.profile.tmp.orig \
+	    ${RDSSHD_STAGE}/dot.profile.tmp.rej
+	cp ${RDSSHD_UTILS}/install.sub ${RDSSHD_STAGE}/install.sub.tmp
+	cp ${RDSSHD_UTILS}/dot.profile ${RDSSHD_STAGE}/dot.profile.tmp
+	cd ${RDSSHD_STAGE} && \
+	    patch -f -s -p0 -F0 < ${RDSSHD_INSTALLERPATCH}
+.if ${RDSSHD_CONSOLE_LOCK:L} == "yes"
+	cd ${RDSSHD_STAGE} && \
+	    patch -f -s -p0 -F0 < ${RDSSHD_CONSOLEPATCH}
+.endif
+	chmod 755 ${RDSSHD_STAGE}/install.sub.tmp
+	mv ${RDSSHD_STAGE}/install.sub.tmp ${RDSSHD_STAGE}/install.sub
+	mv ${RDSSHD_STAGE}/dot.profile.tmp ${RDSSHD_STAGE}/dot.profile
+
+rdsshd-files: rdsshd-check rdsshd-scripts ${RDSSHD_BINS} \
+	    ${RDSSHD_SSHDCONF}
+	install -d ${RDSSHD_STAGE}
+	@if [ "${RDSSHD_CONSOLE_LOCK:L}" = yes ]; then \
+		echo 'COPY ${RDSSHD_INIT} sbin/init'; \
+	fi > ${RDSSHD_STAGE}/list.console
+	sed '/^root:/s|:/bin/ksh$$|:/bin/sh|' \
+	    ${RDSSHD_UTILS}/master.passwd > ${RDSSHD_STAGE}/master.passwd
+	grep '^root:.*:/bin/sh$$' ${RDSSHD_STAGE}/master.passwd >/dev/null
+	sed -e '/^[	 ]*$$/d' -e '/^[	 ]*#/d' \
+	    < "${RDSSHD_AUTHORIZED_KEYS}" > ${RDSSHD_STAGE}/authorized_keys
+	test -s ${RDSSHD_STAGE}/authorized_keys
+	@_n=0; while IFS= read -r _key; do \
+		_n=$$((_n + 1)); \
+		if ! printf '%s\n' "$$_key" | \
+		    ${RDSSHD_STAGE}/bin/ssh-keygen -l -f - >/dev/null 2>&1; then \
+			echo "invalid public key on line $$_n of RDSSHD_AUTHORIZED_KEYS" >&2; \
+			exit 1; \
+		fi; \
+	done < ${RDSSHD_STAGE}/authorized_keys
+	grep '^sshd:' ${RDSSHD_STAGE}/master.passwd >/dev/null || \
+	    grep '^sshd:' ${RDSSHD_TOP}/etc/master.passwd >> \
+	    ${RDSSHD_STAGE}/master.passwd
+	cp ${RDSSHD_UTILS}/group ${RDSSHD_STAGE}/group
+	grep '^sshd:' ${RDSSHD_STAGE}/group >/dev/null || \
+	    grep '^sshd:' ${RDSSHD_TOP}/etc/group >> ${RDSSHD_STAGE}/group
+	{ \
+	    printf 'System hostname = %s\n' '${RDSSHD_AI_HOSTNAME}'; \
+	    printf 'Network interface to configure = %s\n' '${RDSSHD_AI_IF}'; \
+	    printf 'IPv4 address for %s = %s\n' \
+		'${RDSSHD_AI_IF}' '${RDSSHD_AI_IPV4}'; \
+	    case '${RDSSHD_AI_IPV4}' in \
+	    none|autoconf|dhcp) ;; \
+	    *) printf 'Netmask for %s = %s\n' \
+		'${RDSSHD_AI_IF}' '${RDSSHD_AI_NETMASK}'; \
+	       printf 'Default IPv4 route = %s\n' '${RDSSHD_AI_ROUTE}' ;; \
+	    esac; \
+	    printf 'IPv6 address for %s = %s\n' \
+		'${RDSSHD_AI_IF}' '${RDSSHD_AI_IPV6}'; \
+	    printf 'Network interface to configure = done\n'; \
+	    printf 'DNS domain name = %s\n' '${RDSSHD_AI_DOMAIN}'; \
+	    printf 'DNS nameservers = %s\n' '${RDSSHD_AI_DNS}'; \
+	} > ${RDSSHD_STAGE}/auto_install.conf
+	sed 's|^Port .*|Port ${RDSSHD_PORT}|' ${RDSSHD_SSHDCONF} > \
+	    ${RDSSHD_STAGE}/sshd_config
+	rm -f ${RDSSHD_STAGE}/ssh_host_ed25519_key \
+	    ${RDSSHD_STAGE}/ssh_host_ed25519_key.pub \
+	    ${RDSSHD_STAGE}/ssh_host_ed25519_key.test \
+	    ${RDSSHD_STAGE}/ssh_host_ed25519_key.test.pub \
+	    ${RDSSHD_STAGE}/sshd_config.test
+	@if [ -n "${RDSSHD_HOST_KEY}" ]; then \
+		if ! ${RDSSHD_STAGE}/bin/ssh-keygen -y -P '' \
+		    -f "${RDSSHD_HOST_KEY}" 2>/dev/null | grep -q '^ssh-ed25519 '; then \
+			echo "RDSSHD_HOST_KEY is not an unencrypted Ed25519 private key" >&2; \
+			exit 1; \
+		fi; \
+		install -c -m 600 "${RDSSHD_HOST_KEY}" \
+		    ${RDSSHD_STAGE}/ssh_host_ed25519_key; \
+	fi
+	@_key=${RDSSHD_STAGE}/ssh_host_ed25519_key; \
+	_testkey=${RDSSHD_STAGE}/ssh_host_ed25519_key.test; \
+	_testconf=${RDSSHD_STAGE}/sshd_config.test; \
+	if [[ ! -s $$_key ]]; then \
+		_key=$$_testkey; \
+		${RDSSHD_STAGE}/bin/ssh-keygen -q -t ed25519 -N '' \
+		    -f $$_key || exit 1; \
+	fi; \
+	sed "s|^HostKey .*|HostKey $$_key|" \
+	    ${RDSSHD_STAGE}/sshd_config > $$_testconf; \
+	${RDSSHD_STAGE}/bin/sshd -t -f $$_testconf; \
+	_status=$$?; \
+	rm -f $$_testconf $$_testkey $$_testkey.pub; \
+	exit $$_status
+${RDSSHD_STAGE}/bin/sshd: ${RDSSHD_OBJCHECK} ${RDSSHD_MAKEFILE}
+	install -d ${RDSSHD_STAGE}/bin ${RDSSHD_SSHOBJDIR}/sshd
+	cd ${RDSSHD_TOP}/usr.bin/ssh/sshd && \
+	    MAKEOBJDIR=${RDSSHD_SSHOBJDIR}/sshd ${MAKE} ${MFLAGS} \
+	    LDSTATIC="${STATIC}" ZLIB=no
+	install -c -s ${RDSSHD_SSHOBJDIR}/sshd/sshd $@
+
+${RDSSHD_STAGE}/bin/ssh-keygen: ${RDSSHD_OBJCHECK} ${RDSSHD_MAKEFILE}
+	install -d ${RDSSHD_STAGE}/bin ${RDSSHD_SSHOBJDIR}/ssh-keygen
+	cd ${RDSSHD_TOP}/usr.bin/ssh/ssh-keygen && \
+	    MAKEOBJDIR=${RDSSHD_SSHOBJDIR}/ssh-keygen ${MAKE} ${MFLAGS} \
+	    LDSTATIC="${STATIC}" ZLIB=no
+	install -c -s ${RDSSHD_SSHOBJDIR}/ssh-keygen/ssh-keygen $@
+
+${RDSSHD_STAGE}/bin/sshd-session: ${RDSSHD_OBJCHECK} ${RDSSHD_MAKEFILE}
+	install -d ${RDSSHD_STAGE}/bin ${RDSSHD_SSHOBJDIR}/sshd-session
+	cd ${RDSSHD_TOP}/usr.bin/ssh/sshd-session && \
+	    MAKEOBJDIR=${RDSSHD_SSHOBJDIR}/sshd-session ${MAKE} ${MFLAGS} \
+	    LDSTATIC="${STATIC}" ZLIB=no
+	install -c -s ${RDSSHD_SSHOBJDIR}/sshd-session/sshd-session $@
+
+${RDSSHD_STAGE}/bin/sshd-auth: ${RDSSHD_OBJCHECK} ${RDSSHD_MAKEFILE}
+	install -d ${RDSSHD_STAGE}/bin ${RDSSHD_SSHOBJDIR}/sshd-auth
+	cd ${RDSSHD_TOP}/usr.bin/ssh/sshd-auth && \
+	    MAKEOBJDIR=${RDSSHD_SSHOBJDIR}/sshd-auth ${MAKE} ${MFLAGS} \
+	    LDSTATIC="${STATIC}" ZLIB=no
+	install -c -s ${RDSSHD_SSHOBJDIR}/sshd-auth/sshd-auth $@
+
+.PHONY: unconfig-rdsshd
+unconfig-rdsshd:
+	-umount -f ${RDSSHD_MOUNT_POINT} >/dev/null 2>&1
+	@if [ -e ${RDSSHD_VND} ] && [ ! -s ${RDSSHD_VND} ]; then \
+		rm -f ${RDSSHD_VND}; \
+	elif [ -f ${RDSSHD_VND} ]; then \
+		_vnd=`cat ${RDSSHD_VND}`; \
+		_unit=$${_vnd#vnd}; \
+		if [ "vnd$$_unit" != "$$_vnd" ] || [ -z "$$_unit" ]; then \
+			echo "invalid private vnd state: $$_vnd" >&2; exit 1; \
+		fi; \
+		case "$$_unit" in \
+		*[!0-9]*) echo "invalid private vnd state: $$_vnd" >&2; exit 1;; \
+		esac; \
+		_info=`vnconfig -l "$$_vnd"` || exit 1; \
+		case "$$_info" in \
+		"$$_vnd: not in use") rm -f ${RDSSHD_VND} ;; \
+		"$$_vnd: covering ${.OBJDIR}/${RDSSHD_FS} on "*) \
+			vnconfig -u "$$_vnd" && rm -f ${RDSSHD_VND} ;; \
+		*) echo "refusing to detach vnd not owned by rdsshd: $$_info" >&2; \
+			exit 1 ;; \
+		esac; \
+	fi
+
+.ifdef RELEASEDIR
+.PHONY: install-rdsshd
+install-rdsshd: bsdsshd.gz ${RDSSHD_FS}
+	cp bsdsshd.gz ${RELEASEDIR}/bsdsshd.rd
+	cp ${RDSSHD_FS} ${RELEASEDIR}
+.if empty(RDSSHD_HOST_KEY)
+	chmod a+r ${RELEASEDIR}/bsdsshd.rd
+.else
+	chmod 600 ${RELEASEDIR}/bsdsshd.rd ${RELEASEDIR}/${RDSSHD_FS}
+.endif
+.endif
+
+.PHONY: clean-rdsshd clean cleandir
+clean-rdsshd: unconfig-rdsshd
+	rm -f bsdsshd.rd bsdsshd.gz bsdsshd.strip ${RDSSHD_FS} \
+	    ${.OBJDIR}/bsd ${.OBJDIR}/bsd.no-propolice ${RDSSHD_BOOT} \
+	    ${RDSSHD_OBJCHECK}
+	rm -rf ${RDSSHD_BUILDOBJDIR} ${RDSSHD_STAGE} ${RDSSHD_RDOBJDIR} \
+	    ${RDSSHD_SSHOBJDIR} ${RDSSHD_INITOBJDIR} ${RDSSHD_INITSRCDIR}
+	-rmdir ${RDSSHD_MOUNT_POINT}
+
+clean cleandir: clean-rdsshd
+
+.PHONY: prepare-unpatch-rdsshd
+prepare-unpatch-rdsshd: clean-rdsshd
+	@if [ -L ${.CURDIR}/obj ]; then \
+		_obj=`readlink ${.CURDIR}/obj`; \
+		echo "empty private object directory may be removed: $$_obj"; \
+		rm -f ${.CURDIR}/obj; \
+	fi
+
+.include <bsd.obj.mk>
--- /dev/null
+++ b/distrib/amd64/ramdisk_cd/rdsshd/Makefile.init
@@ -0,0 +1,5 @@
+#	$OpenBSD$
+
+.PATH: ${RDSSHD_INITSRCDIR}
+CPPFLAGS+=	-I${.CURDIR}/../../../sbin/init
+.include "${.CURDIR}/Makefile"
--- /dev/null
+++ b/distrib/amd64/ramdisk_cd/rdsshd/README.rdsshd
@@ -0,0 +1,462 @@
+BSDSSHD.RD                 bsdsshd.rd build notes                 BSDSSHD.RD
+
+NAME
+     bsdsshd.rd - OpenBSD/amd64 installer ramdisk with sshd access
+
+DESCRIPTION
+     The patch adds separate bsdsshd.rd and minirootXX_sshd.img targets.
+     The ramdisk configures networking from /auto_install.conf, starts sshd,
+     and leaves the installer environment available to the remote root user.
+
+     The default retains the local installer menu and shell.
+     RDSSHD_CONSOLE_LOCK=yes suppresses the local userland session without
+     suppressing boot-loader or kernel console output.
+
+     The target supports remote storage preparation and installation, including
+     softraid(4) work with bioctl(8).  Use autoinstall(8), install.site(5), and
+     siteXX.tgz when full unattended installation is appropriate.
+
+     The patch adds files only.  Stock source files, Makefiles, bsd.rd,
+     minirootXX.img, and cdXX.iso targets are unchanged.  Enhanced targets are
+     available only through Makefile.rdsshd.
+
+SOURCE LAYOUT
+     Added files:
+
+           distrib/amd64/ramdisk_cd/rdsshd/Makefile.rdsshd
+           distrib/amd64/ramdisk_cd/rdsshd/Makefile.init
+           distrib/amd64/ramdisk_cd/rdsshd/README.rdsshd
+           distrib/amd64/ramdisk_cd/rdsshd/console-lock.sshd.patch
+           distrib/amd64/ramdisk_cd/rdsshd/init.sshd.patch
+           distrib/amd64/ramdisk_cd/rdsshd/installer.sshd.patch
+           distrib/amd64/ramdisk_cd/rdsshd/list.sshd
+           distrib/amd64/ramdisk_cd/rdsshd/root.profile
+           distrib/amd64/ramdisk_cd/rdsshd/sshd_config
+           distrib/amd64/ramdisk_cd/rdsshd/RAMDISK_CD_SSHD
+           distrib/amd64/ramdisk_cd/rdsshd/RAMDISK_CD_SSHD_NO_PROPOLICE
+
+TARGETS
+     rdsshd
+             Builds bsdsshd.rd and minirootXX_sshd.img.
+
+     install-rdsshd
+             With RELEASEDIR set, installs compressed bsdsshd.rd and
+             minirootXX_sshd.img:
+
+                   make -f Makefile.rdsshd install-rdsshd \
+                       RELEASEDIR=/path
+
+     unconfig-rdsshd
+             Unmounts the private mount point and detaches its recorded vnd
+             after an interrupted miniroot build.
+
+     clean, cleandir
+             Remove private rdsshd outputs.  Stock instbin objects remain under
+             normal OpenBSD clean ownership.
+
+     prepare-unpatch-rdsshd
+             Runs private cleanup, prints the wrapper object path, and removes
+             the rdsshd source obj symlink.
+
+BUILD MODEL
+     Private paths below the rdsshd object directory:
+
+           obj/build/kernel       enhanced kernel objects
+           obj/build/instbin      copy of the stock instbin
+           obj/sshobj             static OpenSSH objects
+           obj/initsrc            optional patched init source
+           obj/initobj            optional private init objects
+           obj/stage              generated and overlay files
+           obj/rdobj              ramdisk assembly
+           obj/bsd*               copied enhanced kernels
+           obj/boot               miniroot boot file
+           obj/mnt                miniroot mount point
+           obj/vnd                vnd ownership record
+
+     The kernel is configured with config(8) -b and built below
+     obj/build/kernel.  No sys/arch/amd64/compile directory is added or used.
+     OpenSSH uses explicit private MAKEOBJDIR paths.
+
+     instbin is unmodified.  Its normal objects remain under:
+
+           ${BSDOBJDIR}/distrib/amd64/ramdisk_cd
+           ${BSDOBJDIR}/distrib/special
+           ${BSDOBJDIR}/lib
+
+     The wrapper runs the normal obj targets for lib, distrib/special, and
+     ramdisk_cd.  It builds stock distrib/special/libstubs, then invokes the
+     stock ramdisk_cd instbin target.  That target builds component objects and
+     reduced source libraries.  The wrapper does not transform crunchgen
+     configuration or generated Makefiles.  It copies the completed instbin to
+     obj/build/instbin before ramdisk assembly.
+
+     BSDOBJDIR must exist.  The build verifies that both the wrapper and
+     ramdisk_cd have active object directories distinct from their source
+     directories.  Source-directory fallback is fatal.
+
+     Normal clean and cleandir targets own the shared instbin objects.
+     clean-rdsshd owns only the private copy and enhanced outputs.  Do not run
+     rdsshd concurrently with a normal build or clean using the same instbin
+     objects.
+
+     Normal and enhanced media use separate boot files, mount points, image
+     names, and vnd state.  Cleanup detaches only the recorded vnd covering the
+     absolute enhanced image path.  A missing, malformed, or reused vnd is
+     reported and left attached.
+
+KERNEL
+     RAMDISK_CD_SSHD includes stock RAMDISK_CD, then applies:
+
+           rmoption NO_PROPOLICE
+           rmoption MINIROOTSIZE
+           option MINIROOTSIZE=40960
+           pseudo-device pty 16
+
+     Removing NO_PROPOLICE enables normal kernel stack protection.
+     RDSSHD_KERNEL_STACK_PROTECTOR=no selects
+     RAMDISK_CD_SSHD_NO_PROPOLICE, which retains NO_PROPOLICE.
+
+     MINIROOTSIZE=40960 matches the 20 MiB rdroot.  Sixteen ptys support ssh
+     sessions.  SMALL_KERNEL remains enabled.
+
+     A temporary RDSSHD_RDROOT option protects config(8)'s option-list append
+     pointer while inherited tail entries are removed.  It is absent from the
+     final configuration.
+
+RAMDISK
+     Stock bsd.rd uses the host disktab entry rdrootb:
+
+           MRMAKEFSARGS=-o disklabel=rdrootb,minfree=0,density=4096
+
+     bsdsshd.rd supplies an explicit size to makefs(8):
+
+           -s 20m \
+             -o rdroot,minfree=0,bsize=4096,fsize=512,density=4096
+
+     This does not modify /etc/disktab.
+
+     Stock installer programs remain in instbin.  Separate static PIE OpenSSH
+     executables are installed as:
+
+           /usr/sbin/sshd
+           /usr/bin/ssh-keygen
+           /usr/libexec/sshd-session
+           /usr/libexec/sshd-auth
+
+     The ssh(1) client is not included.  OpenSSH is built through its normal
+     Makefiles with zlib disabled.
+
+     list.sshd adds:
+
+           /etc/ssh/sshd_config
+           /root/.ssh/authorized_keys
+           /root/.profile
+           /auto_install.conf
+           /etc/login.conf
+
+     It also adds the sshd account and pty devices.  Root retains stock
+     instbin's -sh argv link.  root.profile leaves sh mode, sets the installer
+     environment, and sets TERM=vt220.  The Port directive in sshd_config is
+     substituted in private staging.
+
+     installer.sshd.patch modifies private copies of the stock .profile and
+     install.sub.  console-lock.sshd.patch adds the locked early profile path.
+     init.sshd.patch applies only to a private init.c copy.  patch(1) runs
+     non-interactively with zero fuzz.  Context drift fails before replacement.
+
+     Image layout:
+
+           embedded rdroot                 20 MiB
+           outer miniroot                  32768 x 512 bytes (16 MiB)
+
+     minirootXX_sshd.img stores compressed bsdsshd.gz as /bsd.  It does not
+     store the uncompressed bsdsshd.rd.
+
+     Example amd64 artifact sizes:
+
+           boot                    87 KiB
+           bsdsshd.gz             9.0 MiB
+           bsdsshd.rd            27.2 MiB
+           bsdsshd.strip         26.7 MiB
+           minirootXX_sshd.img   16.0 MiB
+
+     A private static init increases the locked image size.
+
+SSHD
+     sshd permits public-key authentication for root only.  Relevant policy:
+
+           AllowUsers root
+           PermitRootLogin prohibit-password
+           PubkeyAuthentication yes
+           AuthenticationMethods publickey
+           PasswordAuthentication no
+           KbdInteractiveAuthentication no
+           Compression no
+           DisableForwarding yes
+
+     The static PIE executables retain normal OpenBSD OpenSSH compiler and
+     linker protections.  Compression is disabled at build and run time.
+
+     RDSSHD_AUTHORIZED_KEYS is mandatory.  Blank and comment lines are removed.
+     Each remaining line must pass ssh-keygen(1) public-key validation.
+
+     No host key is embedded by default.  First boot generates:
+
+           /etc/ssh/ssh_host_ed25519_key
+
+     Normal installer CGI fetches call feed_random before generation.  An
+     unreachable fetch delays sshd until the normal CGI timeout.
+
+     RDSSHD_HOST_KEY may specify an unencrypted Ed25519 private key.  The build
+     validates it and installs it with mode 0600.  The built sshd validates the
+     generated configuration with the supplied key or a temporary test key.
+     Test files remain below obj and are removed.  Build-host ssh configuration
+     and keys are not read or modified.
+
+     An embedded key is recoverable from private objects, kernels, images, and
+     release copies.  Key-bearing kernel and media files are mode 0600.
+
+     sshd starts after donetconfig.  A live numeric PID greater than one in
+     /var/run/sshd.pid suppresses restart.  The ramdisk lacks a process
+     inspection utility.  This tests liveness, not executable identity.
+
+     install.sub creates /var/run/rdsshd.ready only after sshd starts.
+     Bootstrap profiles use this marker because installer status alone does not
+     establish readiness.
+
+CONSOLE AND INSTALLER
+     The initial profile invokes:
+
+           install -af /auto_install.conf
+
+     /auto_install.conf answers only the early network questions.  Static IPv4
+     configuration includes netmask and default route.  autoconf, dhcp, and
+     none omit them.
+
+     With RDSSHD_CONSOLE_LOCK=no, automatic setup begins after the normal menu
+     timeout unless a local operator selects another action.  install.sub exits
+     after starting sshd.  The profile then returns to the local ramdisk shell.
+
+     With RDSSHD_CONSOLE_LOCK=yes, a private static init replaces /sbin/init in
+     the enhanced ramdisk.  Makefile.init uses the stock special/init Makefile,
+     includes stock pathnames.h, and keeps all patched source and objects
+     private.  The bootstrap child retains inherited /dev/null descriptors and
+     never acquires /dev/console as a controlling terminal.
+
+     The locked profile branches before terminal setup.  It retries automatic
+     network and sshd setup until /var/run/rdsshd.ready exists, then sleeps.
+     It never presents a menu or shell.  Init restarts it after exit.
+     RDSSHD_CONSOLE_LOCK=no uses the stock init from instbin.
+
+     After ssh login, run:
+
+           install
+
+     This starts the interactive installer.  At network prompts, choose done to
+     retain the active configuration.  Reconfiguration can drop the ssh session.
+
+SECURITY
+     RDSSHD_CONSOLE_LOCK removes the interactive local installer userland.
+     Boot-loader and kernel consoles remain active.  A local operator can still
+     change early boot state, reset, halt, or deny remote access.
+
+     Console locking does not protect firmware, boot configuration, alternate
+     media, or physical access.  Protect those separately.
+
+     RDSSHD_KERNEL_STACK_PROTECTOR=no deliberately retains NO_PROPOLICE.  This
+     may reduce image size and weakens mitigation of kernel stack corruption.
+
+     Protect RDSSHD_HOST_KEY, the object tree, all key-bearing images, and
+     installed copies.  Use a distinct host key for each machine identity.
+
+BUILD
+     Extract src.tar.gz and sys.tar.gz, then apply the patch:
+
+           cd /usr/src
+           patch -p1 < /root/bsdsshd.rd.patch
+
+     The configured BSDOBJDIR root, normally /usr/obj, must exist.  Build from
+     the added directory:
+
+           cd /usr/src/distrib/amd64/ramdisk_cd/rdsshd
+           make -f Makefile.rdsshd obj
+           make -f Makefile.rdsshd rdsshd \
+               RDSSHD_AUTHORIZED_KEYS=/root/id_ed25519.pub
+
+     Run obj separately.  OpenBSD make selects .OBJDIR at startup and rejects a
+     combined obj and rdsshd invocation.  No top-level /usr/src make obj is
+     required.  The wrapper creates only the normal object links needed by
+     stock instbin; custom components use private paths.
+
+     Lock the local userland:
+
+           make -f Makefile.rdsshd rdsshd \
+               RDSSHD_AUTHORIZED_KEYS=/root/id_ed25519.pub \
+               RDSSHD_CONSOLE_LOCK=yes
+
+     Retain NO_PROPOLICE:
+
+           make -f Makefile.rdsshd rdsshd \
+               RDSSHD_AUTHORIZED_KEYS=/root/id_ed25519.pub \
+               RDSSHD_KERNEL_STACK_PROTECTOR=no
+
+     Embed a stable host key:
+
+           ssh-keygen -q -t ed25519 -N '' \
+               -f /root/rdsshd_host_ed25519_key
+           make -f Makefile.rdsshd rdsshd \
+               RDSSHD_AUTHORIZED_KEYS=/root/id_ed25519.pub \
+               RDSSHD_HOST_KEY=/root/rdsshd_host_ed25519_key
+
+     Outputs:
+
+           /usr/src/distrib/amd64/ramdisk_cd/rdsshd/obj/bsdsshd.rd
+           /usr/src/distrib/amd64/ramdisk_cd/rdsshd/obj/minirootXX_sshd.img
+
+REBUILD AND CLEAN
+     For option or key changes, repeat the rdsshd target.  Normal dependency
+     rules reuse stock instbin components, static OpenSSH, private init, and the
+     selected kernel.  Staging, ramdisk, and media are regenerated.
+
+           make -f Makefile.rdsshd rdsshd \
+               RDSSHD_AUTHORIZED_KEYS=/root/id_ed25519.pub
+
+     After source, compiler, flag, or Makefile changes, clean both ownership
+     domains:
+
+           cd /usr/src/distrib/amd64/ramdisk_cd
+           make cleandir
+           cd /usr/src/distrib/special
+           make cleandir
+           cd /usr/src/lib
+           make cleandir
+
+           cd /usr/src/distrib/amd64/ramdisk_cd/rdsshd
+           make -f Makefile.rdsshd cleandir
+           make -f Makefile.rdsshd obj
+           make -f Makefile.rdsshd rdsshd \
+               RDSSHD_AUTHORIZED_KEYS=/root/id_ed25519.pub
+
+     A top-level cleandir may replace the three stock cleans.  The next rdsshd
+     build recreates required normal object directories.
+
+     After a failed stock instbin trace link, clean ramdisk_cd before retrying.
+     Its instbin.map and reduced archives may be incomplete:
+
+           cd /usr/src/distrib/amd64/ramdisk_cd
+           make cleandir
+
+     After an interrupted miniroot build:
+
+           make -f Makefile.rdsshd unconfig-rdsshd
+
+OPTIONS
+     RDSSHD_AUTHORIZED_KEYS
+             Required public-key file for root's authorized_keys.
+
+     RDSSHD_HOST_KEY
+             Optional unencrypted Ed25519 host private key.  Empty by default;
+             the ramdisk generates a key at each boot.
+
+     RDSSHD_CONSOLE_LOCK
+             yes selects private init and removes the local installer session.
+             no retains the local menu and shell.  Default: no.
+
+     RDSSHD_KERNEL_STACK_PROTECTOR
+             no retains RAMDISK_CD's NO_PROPOLICE option.  yes removes it and
+             enables normal kernel stack protection.  Default: yes.
+
+     RDSSHD_PORT
+             sshd listen port.  Default: 22.
+
+     RDSSHD_AI_IF
+             Network interface.  Default: em0.
+
+     RDSSHD_AI_HOSTNAME
+             Hostname.  Default: rdinstall.
+
+     RDSSHD_AI_IPV4
+             IPv4 address or installer keyword.  Default: autoconf.
+
+     RDSSHD_AI_NETMASK
+             Static IPv4 netmask.  Default: 255.255.255.0.
+
+     RDSSHD_AI_ROUTE
+             Static IPv4 default route.  Default: none.
+
+     RDSSHD_AI_IPV6
+             IPv6 address or installer keyword.  Default: none.
+
+     RDSSHD_AI_DOMAIN
+             DNS domain.  Default: my.domain.
+
+     RDSSHD_AI_DNS
+             DNS nameservers.  Default: none.
+
+     RDSSHD_FSSIZE
+             Outer miniroot size in 512-byte blocks.  Default: 32768 (16 MiB).
+
+     Boolean values are case-insensitive.  Values other than yes and no are
+     rejected.
+
+USE
+     Boot bsdsshd.rd or minirootXX_sshd.img.  Connect after the configured
+     address accepts ssh:
+
+           ssh -i /path/to/private_key root@host
+           install
+
+     A generated host key uses trust on first use and changes at each boot.
+     For stable authenticated identity, supply RDSSHD_HOST_KEY and verify its
+     public-key fingerprint or known_hosts entry.
+
+     With RDSSHD_CONSOLE_LOCK=yes, the local display retains boot-loader and
+     kernel output but presents no installer menu or shell.
+
+GRUB EXAMPLE
+     Copy the uncompressed ramdisk kernel.  Do not rely on gzio:
+
+           cp bsdsshd.rd /boot/bsdsshd.rd
+
+     Add to /etc/grub.d/40_custom:
+
+           menuentry "OpenBSD bsdsshd.rd" {
+                   insmod part_gpt
+                   insmod ext2
+                   insmod bsd
+                   # replace UUID with the /boot filesystem UUID
+                   search --no-floppy --fs-uuid --set=root UUID
+                   kopenbsd /bsdsshd.rd
+           }
+
+     Set in /etc/default/grub:
+
+           GRUB_DEFAULT=saved
+
+     Select one-shot boot:
+
+           update-grub
+           grub-reboot "OpenBSD bsdsshd.rd"
+           grub-editenv list
+           sync
+           reboot
+
+REMOVE PATCH
+     Remove private outputs and the wrapper obj symlink before reversal:
+
+           cd /usr/src/distrib/amd64/ramdisk_cd/rdsshd
+           make -f Makefile.rdsshd prepare-unpatch-rdsshd
+           cd /usr/src
+           patch -R -p1 < /root/bsdsshd.rd.patch
+           rmdir /usr/obj/distrib/amd64/ramdisk_cd/rdsshd
+           rmdir /usr/src/distrib/amd64/ramdisk_cd/rdsshd
+
+     prepare-unpatch-rdsshd prints the recorded object path before removing the
+     symlink.  /usr/obj is the default.  patch(1) removes added files but leaves
+     their empty parent directory.  rmdir refuses non-empty directories.
+
+FILES
+     /usr/src/distrib/amd64/ramdisk_cd/rdsshd/obj/bsdsshd.rd
+     /usr/src/distrib/amd64/ramdisk_cd/rdsshd/obj/minirootXX_sshd.img
+
+BSDSSHD.RD                      July 15, 2026                       BSDSSHD.RD
--- /dev/null
+++ b/distrib/amd64/ramdisk_cd/rdsshd/console-lock.sshd.patch
@@ -0,0 +1,18 @@
+--- dot.profile.tmp
++++ dot.profile.tmp
+@@ -87,6 +87,15 @@
+ 	[[ -x /sbin/dhcpleased ]] && /sbin/dhcpleased 2>/dev/null
+ 	[[ -x /sbin/slaacd ]] && /sbin/slaacd 2>/dev/null
+ 
++	while [[ ! -f /var/run/rdsshd.ready ]]; do
++		/install -af /auto_install.conf
++		sleep 1
++	done
++
++	while :; do
++		sleep 3600
++	done
++
+ 	# Set up some sane tty defaults.
+ 	echo 'erase ^?, werase ^W, kill ^U, intr ^C, status ^T'
+ 	stty newcrt werase ^W intr ^C kill ^U erase ^? status ^T
--- /dev/null
+++ b/distrib/amd64/ramdisk_cd/rdsshd/init.sshd.patch
@@ -0,0 +1,20 @@
+--- init.c.tmp
++++ init.c.tmp
+@@ -517,7 +517,6 @@
+ 		/*
+ 		 * Start the single user session.
+ 		 */
+-		setctty(_PATH_CONSOLE);
+ 
+ #ifdef SECURE
+ 		/*
+@@ -651,8 +650,7 @@
+ 		}
+ 	}
+ 
+-	runcom_mode = FASTBOOT;
+-	return runcom;
++	return single_user;
+ }
+ 
+ /*
--- /dev/null
+++ b/distrib/amd64/ramdisk_cd/rdsshd/installer.sshd.patch
@@ -0,0 +1,71 @@
+--- install.sub.tmp
++++ install.sub.tmp
+@@ -1377,7 +1377,10 @@
+ 		ask_until "$_q (name, lladdr, '?', or 'done')" \
+ 		    ${_p:-$( (get_ifs netboot; get_ifs) | sed q )}
+ 
+-		[[ $resp == done ]] && break
++		if [[ $resp == done ]]; then
++			NIFS=$(ls -1 /tmp/i/hostname.* 2>/dev/null | grep -c ^)
++			break
++		fi
+ 		[[ $resp == '?'  ]] && continue
+ 
+ 		# Quote $resp to prevent user from confusing isin() by
+@@ -3170,6 +3173,21 @@
+ 	echo "\nConfiguring the root disk $ROOTDISK...\n"
+ }
+ 
++rdsshd_start() {
++	local _pid
++	if [[ -s /var/run/sshd.pid ]]; then
++		_pid=$(</var/run/sshd.pid)
++		[[ $_pid == +([0-9]) ]] && (( _pid > 1 )) && \
++		    kill -0 "$_pid" 2>/dev/null && return 0
++	fi
++	if [[ ! -s /etc/ssh/ssh_host_ed25519_key ]]; then
++		/usr/bin/ssh-keygen -q -t ed25519 -N "" \
++		    -f /etc/ssh/ssh_host_ed25519_key || return 1
++	fi
++	/usr/sbin/sshd -t -f /etc/ssh/sshd_config || return 1
++	/usr/sbin/sshd -f /etc/ssh/sshd_config || return 1
++}
++
+ do_install() {
+ 	local _rootkey _rootpass
+ 
+@@ -3190,7 +3208,20 @@
+ 
+ 	# Configure the network.
+ 	donetconfig
++	if $AI && [[ $AI_RESPFILE == /auto_install.conf ]]; then
++		start_cgiinfo
++		wait_cgiinfo
++		rdsshd_start || err_exit "Could not start ramdisk sshd."
++		>/var/run/rdsshd.ready
++		cat <<__EOT
+ 
++ramdisk sshd is running.
++Connect as root with the matching key.
++Run: install
++__EOT
++		exit 0
++	fi
++
+ 	# Fetch list of mirror servers and installer choices from previous runs.
+ 	start_cgiinfo
+ 
+--- dot.profile.tmp
++++ dot.profile.tmp
+@@ -130,6 +130,11 @@
+ 		if $timeout; then
+ 			timeout=false
+ 			echo
++			if [[ -f /auto_install.conf ]]; then
++				/install -af /auto_install.conf
++				[[ -f /var/run/rdsshd.ready ]] && break
++				continue
++			fi
+ 			REPLY=a
+ 		else
+ 			# User has made a choice; stop the read timeout.
--- /dev/null
+++ b/distrib/amd64/ramdisk_cd/rdsshd/list.sshd
@@ -0,0 +1,35 @@
+#	$OpenBSD$
+
+# bsdsshd.rd overlay.
+MKDIR	usr/libexec
+MKDIR	etc/ssh
+MKDIR	root
+MKDIR	root/.ssh
+
+COPY	${OBJDIR}/../stage/sshd_config			etc/ssh/sshd_config
+SPECIAL test ! -s ${OBJDIR}/../stage/ssh_host_ed25519_key || install -c -m 600 -o root -g wheel ${OBJDIR}/../stage/ssh_host_ed25519_key etc/ssh/ssh_host_ed25519_key
+COPY	${OBJDIR}/../stage/auto_install.conf		auto_install.conf
+SCRIPT	${OBJDIR}/../stage/dot.profile			.profile
+SCRIPT	${OBJDIR}/../stage/install.sub			install.sub
+SPECIAL	chmod 755 install.sub
+
+COPY	${OBJDIR}/../stage/master.passwd		etc/master.passwd
+COPY	${OBJDIR}/../stage/group			etc/group
+SPECIAL	pwd_mkdb -p -d etc master.passwd; rm etc/master.passwd
+
+COPY	${CURDIR}/rdsshd/root.profile			root/.profile
+COPY	${OBJDIR}/../stage/authorized_keys		root/.ssh/authorized_keys
+SPECIAL	chmod 700 root root/.ssh; chmod 600 root/.ssh/authorized_keys
+
+SPECIAL	cd dev; sh MAKEDEV pty0 ptm
+
+COPY	${OBJDIR}/../stage/bin/sshd			usr/sbin/sshd
+SPECIAL	chmod 511 usr/sbin/sshd
+COPY	${OBJDIR}/../stage/bin/ssh-keygen		usr/bin/ssh-keygen
+SPECIAL	chmod 555 usr/bin/ssh-keygen
+COPY	${OBJDIR}/../stage/bin/sshd-session		usr/libexec/sshd-session
+SPECIAL	chmod 511 usr/libexec/sshd-session
+COPY	${OBJDIR}/../stage/bin/sshd-auth		usr/libexec/sshd-auth
+SPECIAL	chmod 511 usr/libexec/sshd-auth
+
+COPY	${CURDIR}/../../../etc/etc.amd64/login.conf	etc/login.conf
--- /dev/null
+++ b/distrib/amd64/ramdisk_cd/rdsshd/root.profile
@@ -0,0 +1,16 @@
+set +o sh
+export VNAME=$(sysctl -n kern.osrelease)
+export VERSION="${VNAME%.*}${VNAME#*.}"
+export ARCH=$(sysctl -n hw.machine)
+export OBSD="OpenBSD/$ARCH $VNAME"
+PATH=/sbin:/bin:/usr/bin:/usr/sbin:/
+export PATH
+TERM=vt220
+export TERM
+umask 022
+set -o emacs
+PS1='rd# '
+export PS1
+echo
+echo "ramdisk sshd is running."
+echo "Run install to continue."
--- /dev/null
+++ b/distrib/amd64/ramdisk_cd/rdsshd/sshd_config
@@ -0,0 +1,14 @@
+Port ${RDSSHD_PORT}
+HostKey /etc/ssh/ssh_host_ed25519_key
+AllowUsers root
+PermitRootLogin prohibit-password
+AuthorizedKeysFile .ssh/authorized_keys
+PubkeyAuthentication yes
+AuthenticationMethods publickey
+PasswordAuthentication no
+KbdInteractiveAuthentication no
+Compression no
+PermitUserRC no
+PrintMotd no
+PrintLastLog no
+DisableForwarding yes
--- /dev/null
+++ b/distrib/amd64/ramdisk_cd/rdsshd/RAMDISK_CD_SSHD
@@ -0,0 +1,12 @@
+#	$OpenBSD$
+
+include "arch/amd64/conf/RAMDISK_CD"
+
+# Keep config(8)'s option append pointer valid while removing the tail.
+option		RDSSHD_RDROOT
+rmoption	NO_PROPOLICE
+rmoption	MINIROOTSIZE
+option		MINIROOTSIZE=40960
+rmoption	RDSSHD_RDROOT
+
+pseudo-device	pty	16
--- /dev/null
+++ b/distrib/amd64/ramdisk_cd/rdsshd/RAMDISK_CD_SSHD_NO_PROPOLICE
@@ -0,0 +1,11 @@
+#	$OpenBSD$
+
+include "arch/amd64/conf/RAMDISK_CD"
+
+# Keep config(8)'s option append pointer valid while removing the tail.
+option		RDSSHD_RDROOT
+rmoption	MINIROOTSIZE
+option		MINIROOTSIZE=40960
+rmoption	RDSSHD_RDROOT
+
+pseudo-device	pty	16

SOURCE AND TARGETS

Source layout

The patch adds one source directory containing eleven files:

Added filesraw
distrib/amd64/ramdisk_cd/rdsshd/Makefile.rdsshd
distrib/amd64/ramdisk_cd/rdsshd/Makefile.init
distrib/amd64/ramdisk_cd/rdsshd/README.rdsshd
distrib/amd64/ramdisk_cd/rdsshd/console-lock.sshd.patch
distrib/amd64/ramdisk_cd/rdsshd/init.sshd.patch
distrib/amd64/ramdisk_cd/rdsshd/installer.sshd.patch
distrib/amd64/ramdisk_cd/rdsshd/list.sshd
distrib/amd64/ramdisk_cd/rdsshd/root.profile
distrib/amd64/ramdisk_cd/rdsshd/sshd_config
distrib/amd64/ramdisk_cd/rdsshd/RAMDISK_CD_SSHD
distrib/amd64/ramdisk_cd/rdsshd/RAMDISK_CD_SSHD_NO_PROPOLICE

Targets

rdsshd
Builds both enhanced images.
install-rdsshd
Installs compressed bsdsshd.rd and the miniroot in RELEASEDIR.
unconfig-rdsshd
Releases private mount and vnd state after an interrupted build.
clean, cleandir
Remove private outputs. Stock instbin objects remain untouched.
prepare-unpatch-rdsshd
Cleans private outputs, prints the wrapper object path, and removes the source obj symlink.
Install release filesraw
make -f Makefile.rdsshd install-rdsshd RELEASEDIR=/path
Makefile.rdsshdEnhanced wrapper and targetsraw
#	$OpenBSD$

.include <bsd.own.mk>

RDSSHD_AUTHORIZED_KEYS?=
RDSSHD_HOST_KEY?=
RDSSHD_CONSOLE_LOCK?=	no
RDSSHD_KERNEL_STACK_PROTECTOR?=	yes
RDSSHD_PORT?=	22
RDSSHD_AI_IF?=	em0
RDSSHD_AI_HOSTNAME?=	rdinstall
RDSSHD_AI_IPV4?=	autoconf
RDSSHD_AI_NETMASK?=	255.255.255.0
RDSSHD_AI_ROUTE?=	none
RDSSHD_AI_IPV6?=	none
RDSSHD_AI_DOMAIN?=	my.domain
RDSSHD_AI_DNS?=	none
RDSSHD_FSSIZE?=	32768

RDSSHD_BASEDIR=	${.CURDIR}/..
RDSSHD_TOP=	${.CURDIR}/../../../..
RDSSHD_UTILS=	${RDSSHD_BASEDIR}/../../miniroot
RDSSHD_MTREE=	${RDSSHD_UTILS}/mtree.conf
RDSSHD_EFIBOOT=	${DESTDIR}/usr/mdec/BOOTX64.EFI \
		${DESTDIR}/usr/mdec/BOOTIA32.EFI
RDSSHD_MOUNT_ARGS_MSDOS=	-o-s

RDSSHD_RAMDISK=	RAMDISK_CD_SSHD
RDSSHD_KERNEL=	${.OBJDIR}/bsd
.if ${RDSSHD_KERNEL_STACK_PROTECTOR:L} == "no"
RDSSHD_RAMDISK=	RAMDISK_CD_SSHD_NO_PROPOLICE
RDSSHD_KERNEL=	${.OBJDIR}/bsd.no-propolice
.endif
RDSSHD_FS=	miniroot${OSrev}_sshd.img
RDSSHD_BUILDOBJDIR=	${.OBJDIR}/build
RDSSHD_KERNELOBJDIR=	${RDSSHD_BUILDOBJDIR}/kernel/${RDSSHD_RAMDISK}
RDSSHD_INSTBIN=	${RDSSHD_BUILDOBJDIR}/instbin
RDSSHD_STAGE=	${.OBJDIR}/stage
RDSSHD_RDOBJDIR=	${.OBJDIR}/rdobj
RDSSHD_SSHOBJDIR=	${.OBJDIR}/sshobj
RDSSHD_INITOBJDIR=	${.OBJDIR}/initobj
RDSSHD_INITSRCDIR=	${.OBJDIR}/initsrc
RDSSHD_MOUNT_POINT=	${.OBJDIR}/mnt
RDSSHD_VND=	${.OBJDIR}/vnd
RDSSHD_BOOT=	${.OBJDIR}/boot
RDSSHD_OBJCHECK=	${.OBJDIR}/.rdsshd-obj-ok
RDSSHD_MAKEFILE=	${.CURDIR}/Makefile.rdsshd
RDSSHD_INITMAKEFILE=	${.CURDIR}/Makefile.init
RDSSHD_KERNELCONF=	${.CURDIR}/${RDSSHD_RAMDISK}
RDSSHD_BASECONF=	${RDSSHD_TOP}/sys/arch/${MACHINE}/conf/RAMDISK_CD
RDSSHD_KERNELCONFDEPS=	${RDSSHD_BASECONF}
RDSSHD_LISTS=	${RDSSHD_BASEDIR}/list ${.CURDIR}/list.sshd \
		${RDSSHD_STAGE}/list.console
RDSSHD_INITSRC=	${RDSSHD_INITSRCDIR}/init.c
RDSSHD_INIT=	${RDSSHD_STAGE}/init
RDSSHD_INSTALLERPATCH=	${.CURDIR}/installer.sshd.patch
RDSSHD_CONSOLEPATCH=	${.CURDIR}/console-lock.sshd.patch
RDSSHD_INITPATCH=	${.CURDIR}/init.sshd.patch
RDSSHD_SSHDCONF=	${.CURDIR}/sshd_config
RDSSHD_MRMAKEFSARGS?=	-s 20m \
		-o rdroot,minfree=0,bsize=4096,fsize=512,density=4096
RDSSHD_BINS=	${RDSSHD_STAGE}/bin/sshd \
		${RDSSHD_STAGE}/bin/ssh-keygen \
		${RDSSHD_STAGE}/bin/sshd-session \
		${RDSSHD_STAGE}/bin/sshd-auth

RDSSHD_INITDEP=
.if ${RDSSHD_CONSOLE_LOCK:L} == "yes"
RDSSHD_INITDEP=	${RDSSHD_INIT}
.endif

.PHONY: rdsshd
rdsshd: bsdsshd.rd ${RDSSHD_FS}

.PHONY: rdsshd-objcheck
rdsshd-objcheck: ${RDSSHD_OBJCHECK}

${RDSSHD_OBJCHECK}:
	@if [ "${.OBJDIR}" = "${.CURDIR}" ]; then \
		echo "private object directory is not active;" >&2; \
		echo "run 'make -f Makefile.rdsshd obj' separately first" >&2; \
		exit 1; \
	fi
	touch $@

${RDSSHD_KERNEL}: ${RDSSHD_OBJCHECK} ${RDSSHD_MAKEFILE} \
	    ${RDSSHD_KERNELCONF} ${RDSSHD_KERNELCONFDEPS}
	install -d -o ${BUILDUSER} -g ${WOBJGROUP} ${RDSSHD_KERNELOBJDIR}
	su ${BUILDUSER} -c \
	    'config -b ${RDSSHD_KERNELOBJDIR} -s ${RDSSHD_TOP}/sys \
	    ${RDSSHD_KERNELCONF} && cd ${RDSSHD_KERNELOBJDIR} && \
	    MAKEOBJDIR=${RDSSHD_KERNELOBJDIR} ${MAKE} clean && \
	    exec env MAKEOBJDIR=${RDSSHD_KERNELOBJDIR} ${MAKE} ${MFLAGS}'
	cp -p ${RDSSHD_KERNELOBJDIR}/bsd $@

bsdsshd.gz: bsdsshd.rd
	objcopy -g -x -R .comment -R .SUNW_ctf \
	    -K rd_root_size -K rd_root_image \
	    bsdsshd.rd bsdsshd.strip
	gzip -9cn bsdsshd.strip > bsdsshd.gz
.if !empty(RDSSHD_HOST_KEY)
	chmod 600 bsdsshd.strip $@
.endif

.PHONY: rdsshd-stock-deps rdsshd-instbin
rdsshd-stock-deps: ${RDSSHD_OBJCHECK} ${RDSSHD_MAKEFILE}
	@_objroot=`cd ${RDSSHD_BASEDIR} && ${MAKE} -V BSDOBJDIR`; \
	if [ ! -d "$$_objroot" ]; then \
		echo "normal OpenBSD object root does not exist: $$_objroot" >&2; \
		echo "create it before building rdsshd" >&2; \
		exit 1; \
	fi
	cd ${RDSSHD_TOP}/lib && ${MAKE} obj
	cd ${RDSSHD_TOP}/distrib/special && ${MAKE} obj
	cd ${RDSSHD_BASEDIR} && ${MAKE} obj
	@_srcdir=`cd ${RDSSHD_BASEDIR} && pwd`; \
	_objdir=`cd ${RDSSHD_BASEDIR} && ${MAKE} -V .OBJDIR`; \
	if [ "$$_objdir" = "$$_srcdir" ] || [ ! -d "$$_objdir" ]; then \
		echo "normal ramdisk_cd object directory is not active" >&2; \
		exit 1; \
	fi
	cd ${RDSSHD_TOP}/distrib/special/libstubs && ${MAKE} ${MFLAGS}

rdsshd-instbin: rdsshd-stock-deps
	cd ${RDSSHD_BASEDIR} && ${MAKE} ${MFLAGS} instbin
	install -d ${RDSSHD_BUILDOBJDIR}
	@_objdir=`cd ${RDSSHD_BASEDIR} && ${MAKE} -V .OBJDIR`; \
	if [ ! -f "$$_objdir/instbin" ]; then \
		echo "stock ramdisk_cd instbin was not built" >&2; \
		exit 1; \
	fi; \
	cp -p "$$_objdir/instbin" ${RDSSHD_INSTBIN}

bsdsshd.rd: rdsshd-files ${RDSSHD_INITDEP} rdsshd-instbin \
	    ${RDSSHD_KERNEL}
	install -d ${RDSSHD_RDOBJDIR}
	rm -f ${RDSSHD_RDOBJDIR}/instbin \
	    ${RDSSHD_RDOBJDIR}/mr.fs \
	    ${RDSSHD_RDOBJDIR}/bsd.rd
	cp -p ${RDSSHD_INSTBIN} ${RDSSHD_RDOBJDIR}/instbin
	rm -rf ${RDSSHD_RDOBJDIR}/mr.fs.d
	install -d -o root -g wheel ${RDSSHD_RDOBJDIR}/mr.fs.d
	mtree -def ${RDSSHD_MTREE} -p ${RDSSHD_RDOBJDIR}/mr.fs.d -u
	CURDIR=${RDSSHD_BASEDIR} OBJDIR=${RDSSHD_RDOBJDIR} OSrev=${OSrev} \
	    TARGDIR=${RDSSHD_RDOBJDIR}/mr.fs.d UTILS=${RDSSHD_UTILS} \
	    RELEASEDIR=${RELEASEDIR} sh ${RDSSHD_UTILS}/runlist.sh \
	    ${RDSSHD_LISTS}
	rm ${RDSSHD_RDOBJDIR}/mr.fs.d/instbin
	makefs ${RDSSHD_MRMAKEFSARGS} ${RDSSHD_RDOBJDIR}/mr.fs \
	    ${RDSSHD_RDOBJDIR}/mr.fs.d
	cp -p ${RDSSHD_KERNEL} ${RDSSHD_RDOBJDIR}/bsd.rd
	rdsetroot ${RDSSHD_RDOBJDIR}/bsd.rd ${RDSSHD_RDOBJDIR}/mr.fs
	cp ${RDSSHD_RDOBJDIR}/bsd.rd $@
.if !empty(RDSSHD_HOST_KEY)
	chmod 600 ${RDSSHD_RDOBJDIR}/mr.fs \
	    ${RDSSHD_RDOBJDIR}/bsd.rd $@
.endif

${RDSSHD_FS}: bsdsshd.gz
	-umount -f ${RDSSHD_MOUNT_POINT} >/dev/null 2>&1
	@if [ -e ${RDSSHD_VND} ] && [ ! -s ${RDSSHD_VND} ]; then \
		rm -f ${RDSSHD_VND}; \
	fi
	@if [ -e ${RDSSHD_VND} ]; then \
		echo "stale private vnd state; run" \
		    "'make -f Makefile.rdsshd unconfig-rdsshd' first" >&2; \
		exit 1; \
	fi
	install -d ${RDSSHD_MOUNT_POINT}
	dd if=/dev/zero of=${RDSSHD_FS} bs=512 count=${RDSSHD_FSSIZE}
	vnconfig -v ${.OBJDIR}/${RDSSHD_FS} > ${RDSSHD_VND}
	fdisk -yi -l ${RDSSHD_FSSIZE} -b 960 -f ${DESTDIR}/usr/mdec/mbr \
	    `cat ${RDSSHD_VND}`
	echo '/ *' | disklabel -wAT- `cat ${RDSSHD_VND}`
	newfs -t msdos /dev/r`cat ${RDSSHD_VND}`i
	mount ${RDSSHD_MOUNT_ARGS_MSDOS} /dev/`cat ${RDSSHD_VND}`i \
	    ${RDSSHD_MOUNT_POINT}
	mkdir -p ${RDSSHD_MOUNT_POINT}/efi/boot
	cp ${RDSSHD_EFIBOOT} ${RDSSHD_MOUNT_POINT}/efi/boot
	umount ${RDSSHD_MOUNT_POINT}
	newfs -O 1 -m 0 -o space -i 524288 -c ${RDSSHD_FSSIZE} \
	    /dev/r`cat ${RDSSHD_VND}`a
	mount /dev/`cat ${RDSSHD_VND}`a ${RDSSHD_MOUNT_POINT}
	objcopy -S -R .comment ${DESTDIR}/usr/mdec/boot ${RDSSHD_BOOT}
	installboot -v -r ${RDSSHD_MOUNT_POINT} `cat ${RDSSHD_VND}` \
	    ${DESTDIR}/usr/mdec/biosboot ${RDSSHD_BOOT}
	install -c -m 555 -o root -g wheel bsdsshd.gz \
	    ${RDSSHD_MOUNT_POINT}/bsd
	df -i ${RDSSHD_MOUNT_POINT}
	umount ${RDSSHD_MOUNT_POINT}
	vnconfig -u `cat ${RDSSHD_VND}`
	rm -f ${RDSSHD_VND}
.if !empty(RDSSHD_HOST_KEY)
	chmod 600 $@
.endif

.PHONY: rdsshd-check rdsshd-files rdsshd-scripts
rdsshd-check:
	@if [ -z "${RDSSHD_AUTHORIZED_KEYS}" ]; then \
		echo "set RDSSHD_AUTHORIZED_KEYS to a public key file" >&2; \
		exit 1; \
	fi
	@case "${RDSSHD_CONSOLE_LOCK:L}" in \
	yes|no) ;; \
	*) echo "RDSSHD_CONSOLE_LOCK must be yes or no" >&2; exit 1;; \
	esac
	@case "${RDSSHD_KERNEL_STACK_PROTECTOR:L}" in \
	yes|no) ;; \
	*) echo "RDSSHD_KERNEL_STACK_PROTECTOR must be yes or no" >&2; exit 1;; \
	esac

${RDSSHD_INITSRC}: ${RDSSHD_OBJCHECK} ${RDSSHD_TOP}/sbin/init/init.c \
	    ${RDSSHD_INITPATCH}
	install -d ${RDSSHD_INITSRCDIR}
	rm -f ${RDSSHD_INITSRC}.tmp ${RDSSHD_INITSRC}.tmp.orig \
	    ${RDSSHD_INITSRC}.tmp.rej
	cp ${RDSSHD_TOP}/sbin/init/init.c ${RDSSHD_INITSRC}.tmp
	cd ${RDSSHD_INITSRCDIR} && \
	    patch -f -s -p0 -F0 < ${RDSSHD_INITPATCH}
	mv ${RDSSHD_INITSRC}.tmp ${RDSSHD_INITSRC}

${RDSSHD_INIT}: ${RDSSHD_INITSRC} \
	    ${RDSSHD_TOP}/sbin/init/pathnames.h ${RDSSHD_INITMAKEFILE} \
	    ${RDSSHD_BASEDIR}/../../special/init/Makefile \
	    ${RDSSHD_BASEDIR}/../../special/Makefile.inc
	install -d ${RDSSHD_INITOBJDIR} ${RDSSHD_STAGE}
	cd ${RDSSHD_BASEDIR}/../../special/init && \
	    MAKEOBJDIR=${RDSSHD_INITOBJDIR} ${MAKE} ${MFLAGS} \
	    -f ${RDSSHD_INITMAKEFILE} \
	    RDSSHD_INITSRCDIR=${RDSSHD_INITSRCDIR} init
	install -c -s ${RDSSHD_INITOBJDIR}/init $@

rdsshd-scripts: ${RDSSHD_OBJCHECK} ${RDSSHD_UTILS}/install.sub \
	    ${RDSSHD_UTILS}/dot.profile \
	    ${RDSSHD_INSTALLERPATCH} ${RDSSHD_CONSOLEPATCH}
	install -d ${RDSSHD_STAGE}
	rm -f ${RDSSHD_STAGE}/install.sub.tmp \
	    ${RDSSHD_STAGE}/install.sub.tmp.orig \
	    ${RDSSHD_STAGE}/install.sub.tmp.rej \
	    ${RDSSHD_STAGE}/dot.profile.tmp \
	    ${RDSSHD_STAGE}/dot.profile.tmp.orig \
	    ${RDSSHD_STAGE}/dot.profile.tmp.rej
	cp ${RDSSHD_UTILS}/install.sub ${RDSSHD_STAGE}/install.sub.tmp
	cp ${RDSSHD_UTILS}/dot.profile ${RDSSHD_STAGE}/dot.profile.tmp
	cd ${RDSSHD_STAGE} && \
	    patch -f -s -p0 -F0 < ${RDSSHD_INSTALLERPATCH}
.if ${RDSSHD_CONSOLE_LOCK:L} == "yes"
	cd ${RDSSHD_STAGE} && \
	    patch -f -s -p0 -F0 < ${RDSSHD_CONSOLEPATCH}
.endif
	chmod 755 ${RDSSHD_STAGE}/install.sub.tmp
	mv ${RDSSHD_STAGE}/install.sub.tmp ${RDSSHD_STAGE}/install.sub
	mv ${RDSSHD_STAGE}/dot.profile.tmp ${RDSSHD_STAGE}/dot.profile

rdsshd-files: rdsshd-check rdsshd-scripts ${RDSSHD_BINS} \
	    ${RDSSHD_SSHDCONF}
	install -d ${RDSSHD_STAGE}
	@if [ "${RDSSHD_CONSOLE_LOCK:L}" = yes ]; then \
		echo 'COPY ${RDSSHD_INIT} sbin/init'; \
	fi > ${RDSSHD_STAGE}/list.console
	sed '/^root:/s|:/bin/ksh$$|:/bin/sh|' \
	    ${RDSSHD_UTILS}/master.passwd > ${RDSSHD_STAGE}/master.passwd
	grep '^root:.*:/bin/sh$$' ${RDSSHD_STAGE}/master.passwd >/dev/null
	sed -e '/^[	 ]*$$/d' -e '/^[	 ]*#/d' \
	    < "${RDSSHD_AUTHORIZED_KEYS}" > ${RDSSHD_STAGE}/authorized_keys
	test -s ${RDSSHD_STAGE}/authorized_keys
	@_n=0; while IFS= read -r _key; do \
		_n=$$((_n + 1)); \
		if ! printf '%s\n' "$$_key" | \
		    ${RDSSHD_STAGE}/bin/ssh-keygen -l -f - >/dev/null 2>&1; then \
			echo "invalid public key on line $$_n of RDSSHD_AUTHORIZED_KEYS" >&2; \
			exit 1; \
		fi; \
	done < ${RDSSHD_STAGE}/authorized_keys
	grep '^sshd:' ${RDSSHD_STAGE}/master.passwd >/dev/null || \
	    grep '^sshd:' ${RDSSHD_TOP}/etc/master.passwd >> \
	    ${RDSSHD_STAGE}/master.passwd
	cp ${RDSSHD_UTILS}/group ${RDSSHD_STAGE}/group
	grep '^sshd:' ${RDSSHD_STAGE}/group >/dev/null || \
	    grep '^sshd:' ${RDSSHD_TOP}/etc/group >> ${RDSSHD_STAGE}/group
	{ \
	    printf 'System hostname = %s\n' '${RDSSHD_AI_HOSTNAME}'; \
	    printf 'Network interface to configure = %s\n' '${RDSSHD_AI_IF}'; \
	    printf 'IPv4 address for %s = %s\n' \
		'${RDSSHD_AI_IF}' '${RDSSHD_AI_IPV4}'; \
	    case '${RDSSHD_AI_IPV4}' in \
	    none|autoconf|dhcp) ;; \
	    *) printf 'Netmask for %s = %s\n' \
		'${RDSSHD_AI_IF}' '${RDSSHD_AI_NETMASK}'; \
	       printf 'Default IPv4 route = %s\n' '${RDSSHD_AI_ROUTE}' ;; \
	    esac; \
	    printf 'IPv6 address for %s = %s\n' \
		'${RDSSHD_AI_IF}' '${RDSSHD_AI_IPV6}'; \
	    printf 'Network interface to configure = done\n'; \
	    printf 'DNS domain name = %s\n' '${RDSSHD_AI_DOMAIN}'; \
	    printf 'DNS nameservers = %s\n' '${RDSSHD_AI_DNS}'; \
	} > ${RDSSHD_STAGE}/auto_install.conf
	sed 's|^Port .*|Port ${RDSSHD_PORT}|' ${RDSSHD_SSHDCONF} > \
	    ${RDSSHD_STAGE}/sshd_config
	rm -f ${RDSSHD_STAGE}/ssh_host_ed25519_key \
	    ${RDSSHD_STAGE}/ssh_host_ed25519_key.pub \
	    ${RDSSHD_STAGE}/ssh_host_ed25519_key.test \
	    ${RDSSHD_STAGE}/ssh_host_ed25519_key.test.pub \
	    ${RDSSHD_STAGE}/sshd_config.test
	@if [ -n "${RDSSHD_HOST_KEY}" ]; then \
		if ! ${RDSSHD_STAGE}/bin/ssh-keygen -y -P '' \
		    -f "${RDSSHD_HOST_KEY}" 2>/dev/null | grep -q '^ssh-ed25519 '; then \
			echo "RDSSHD_HOST_KEY is not an unencrypted Ed25519 private key" >&2; \
			exit 1; \
		fi; \
		install -c -m 600 "${RDSSHD_HOST_KEY}" \
		    ${RDSSHD_STAGE}/ssh_host_ed25519_key; \
	fi
	@_key=${RDSSHD_STAGE}/ssh_host_ed25519_key; \
	_testkey=${RDSSHD_STAGE}/ssh_host_ed25519_key.test; \
	_testconf=${RDSSHD_STAGE}/sshd_config.test; \
	if [[ ! -s $$_key ]]; then \
		_key=$$_testkey; \
		${RDSSHD_STAGE}/bin/ssh-keygen -q -t ed25519 -N '' \
		    -f $$_key || exit 1; \
	fi; \
	sed "s|^HostKey .*|HostKey $$_key|" \
	    ${RDSSHD_STAGE}/sshd_config > $$_testconf; \
	${RDSSHD_STAGE}/bin/sshd -t -f $$_testconf; \
	_status=$$?; \
	rm -f $$_testconf $$_testkey $$_testkey.pub; \
	exit $$_status
${RDSSHD_STAGE}/bin/sshd: ${RDSSHD_OBJCHECK} ${RDSSHD_MAKEFILE}
	install -d ${RDSSHD_STAGE}/bin ${RDSSHD_SSHOBJDIR}/sshd
	cd ${RDSSHD_TOP}/usr.bin/ssh/sshd && \
	    MAKEOBJDIR=${RDSSHD_SSHOBJDIR}/sshd ${MAKE} ${MFLAGS} \
	    LDSTATIC="${STATIC}" ZLIB=no
	install -c -s ${RDSSHD_SSHOBJDIR}/sshd/sshd $@

${RDSSHD_STAGE}/bin/ssh-keygen: ${RDSSHD_OBJCHECK} ${RDSSHD_MAKEFILE}
	install -d ${RDSSHD_STAGE}/bin ${RDSSHD_SSHOBJDIR}/ssh-keygen
	cd ${RDSSHD_TOP}/usr.bin/ssh/ssh-keygen && \
	    MAKEOBJDIR=${RDSSHD_SSHOBJDIR}/ssh-keygen ${MAKE} ${MFLAGS} \
	    LDSTATIC="${STATIC}" ZLIB=no
	install -c -s ${RDSSHD_SSHOBJDIR}/ssh-keygen/ssh-keygen $@

${RDSSHD_STAGE}/bin/sshd-session: ${RDSSHD_OBJCHECK} ${RDSSHD_MAKEFILE}
	install -d ${RDSSHD_STAGE}/bin ${RDSSHD_SSHOBJDIR}/sshd-session
	cd ${RDSSHD_TOP}/usr.bin/ssh/sshd-session && \
	    MAKEOBJDIR=${RDSSHD_SSHOBJDIR}/sshd-session ${MAKE} ${MFLAGS} \
	    LDSTATIC="${STATIC}" ZLIB=no
	install -c -s ${RDSSHD_SSHOBJDIR}/sshd-session/sshd-session $@

${RDSSHD_STAGE}/bin/sshd-auth: ${RDSSHD_OBJCHECK} ${RDSSHD_MAKEFILE}
	install -d ${RDSSHD_STAGE}/bin ${RDSSHD_SSHOBJDIR}/sshd-auth
	cd ${RDSSHD_TOP}/usr.bin/ssh/sshd-auth && \
	    MAKEOBJDIR=${RDSSHD_SSHOBJDIR}/sshd-auth ${MAKE} ${MFLAGS} \
	    LDSTATIC="${STATIC}" ZLIB=no
	install -c -s ${RDSSHD_SSHOBJDIR}/sshd-auth/sshd-auth $@

.PHONY: unconfig-rdsshd
unconfig-rdsshd:
	-umount -f ${RDSSHD_MOUNT_POINT} >/dev/null 2>&1
	@if [ -e ${RDSSHD_VND} ] && [ ! -s ${RDSSHD_VND} ]; then \
		rm -f ${RDSSHD_VND}; \
	elif [ -f ${RDSSHD_VND} ]; then \
		_vnd=`cat ${RDSSHD_VND}`; \
		_unit=$${_vnd#vnd}; \
		if [ "vnd$$_unit" != "$$_vnd" ] || [ -z "$$_unit" ]; then \
			echo "invalid private vnd state: $$_vnd" >&2; exit 1; \
		fi; \
		case "$$_unit" in \
		*[!0-9]*) echo "invalid private vnd state: $$_vnd" >&2; exit 1;; \
		esac; \
		_info=`vnconfig -l "$$_vnd"` || exit 1; \
		case "$$_info" in \
		"$$_vnd: not in use") rm -f ${RDSSHD_VND} ;; \
		"$$_vnd: covering ${.OBJDIR}/${RDSSHD_FS} on "*) \
			vnconfig -u "$$_vnd" && rm -f ${RDSSHD_VND} ;; \
		*) echo "refusing to detach vnd not owned by rdsshd: $$_info" >&2; \
			exit 1 ;; \
		esac; \
	fi

.ifdef RELEASEDIR
.PHONY: install-rdsshd
install-rdsshd: bsdsshd.gz ${RDSSHD_FS}
	cp bsdsshd.gz ${RELEASEDIR}/bsdsshd.rd
	cp ${RDSSHD_FS} ${RELEASEDIR}
.if empty(RDSSHD_HOST_KEY)
	chmod a+r ${RELEASEDIR}/bsdsshd.rd
.else
	chmod 600 ${RELEASEDIR}/bsdsshd.rd ${RELEASEDIR}/${RDSSHD_FS}
.endif
.endif

.PHONY: clean-rdsshd clean cleandir
clean-rdsshd: unconfig-rdsshd
	rm -f bsdsshd.rd bsdsshd.gz bsdsshd.strip ${RDSSHD_FS} \
	    ${.OBJDIR}/bsd ${.OBJDIR}/bsd.no-propolice ${RDSSHD_BOOT} \
	    ${RDSSHD_OBJCHECK}
	rm -rf ${RDSSHD_BUILDOBJDIR} ${RDSSHD_STAGE} ${RDSSHD_RDOBJDIR} \
	    ${RDSSHD_SSHOBJDIR} ${RDSSHD_INITOBJDIR} ${RDSSHD_INITSRCDIR}
	-rmdir ${RDSSHD_MOUNT_POINT}

clean cleandir: clean-rdsshd

.PHONY: prepare-unpatch-rdsshd
prepare-unpatch-rdsshd: clean-rdsshd
	@if [ -L ${.CURDIR}/obj ]; then \
		_obj=`readlink ${.CURDIR}/obj`; \
		echo "empty private object directory may be removed: $$_obj"; \
		rm -f ${.CURDIR}/obj; \
	fi

.include <bsd.obj.mk>

BUILD MODEL

Object ownership

Object pathsraw
Private rdsshd paths:

obj/build/kernel       enhanced kernel objects
obj/build/instbin      copy of stock instbin
obj/sshobj             static OpenSSH objects
obj/initsrc            optional patched init source
obj/initobj            optional private init objects
obj/stage              generated and overlay files
obj/rdobj              ramdisk assembly
obj/bsd*               copied enhanced kernels
obj/boot               miniroot boot file
obj/mnt                miniroot mount point
obj/vnd                vnd ownership record

Normal OpenBSD paths used by stock instbin:

${BSDOBJDIR}/distrib/amd64/ramdisk_cd
${BSDOBJDIR}/distrib/special
${BSDOBJDIR}/lib

The enhanced kernel, OpenSSH programs, optional init, staging tree, ramdisk, media, mount point, and vnd record remain private. The kernel uses config(8) -b. OpenSSH uses explicit private MAKEOBJDIR paths. No directory below sys/arch/amd64/compile is added or used.

BSDOBJDIR must exist. The build rejects source-directory object fallback for both the wrapper and ramdisk_cd. Normal and enhanced media use distinct boot files, image names, mount points, and vnd state.

Cleanup detaches only the recorded vnd covering the absolute enhanced image path. A missing, malformed, or reused vnd is reported and retained.

Stock instbin

Installer instbin is unmodified. The wrapper runs normal obj targets for lib, distrib/special, and ramdisk_cd. It builds stock distrib/special/libstubs, invokes the stock instbin target, then copies the result into private staging.

Component objects, reduced libraries, and crunchgen output retain their normal paths below ${BSDOBJDIR}. The wrapper does not rewrite generated crunchgen files. Normal OpenBSD clean targets own these stock objects. Do not run enhanced and normal builds or cleans concurrently when they share instbin objects.

KERNEL

RAMDISK_CD_SSHD includes stock RAMDISK_CD, then changes only the inherited stack-protection option, rdroot size, and pty count:

RAMDISK_CD_SSHDraw
#	$OpenBSD$

include "arch/amd64/conf/RAMDISK_CD"

# Keep config(8)'s option append pointer valid while removing the tail.
option		RDSSHD_RDROOT
rmoption	NO_PROPOLICE
rmoption	MINIROOTSIZE
option		MINIROOTSIZE=40960
rmoption	RDSSHD_RDROOT

pseudo-device	pty	16

Removing NO_PROPOLICE enables normal kernel stack protection. RDSSHD_KERNEL_STACK_PROTECTOR=no selects a separate overlay which retains NO_PROPOLICE.

RAMDISK_CD_SSHD_NO_PROPOLICEraw
#	$OpenBSD$

include "arch/amd64/conf/RAMDISK_CD"

# Keep config(8)'s option append pointer valid while removing the tail.
option		RDSSHD_RDROOT
rmoption	MINIROOTSIZE
option		MINIROOTSIZE=40960
rmoption	RDSSHD_RDROOT

pseudo-device	pty	16

MINIROOTSIZE=40960 matches the 20 MiB rdroot. Sixteen ptys support ssh sessions. SMALL_KERNEL remains enabled.

A temporary RDSSHD_RDROOT option protects config(8)'s option-list append pointer while inherited tail entries are removed. It is absent from the final configuration.

RAMDISK

Filesystem

Stock bsd.rd uses the host disktab entry rdrootb:

Stock rdrootraw
MRMAKEFSARGS=-o disklabel=rdrootb,minfree=0,density=4096

The enhanced target passes an explicit 20 MiB layout to makefs(8). It does not modify /etc/disktab.

Enhanced rdrootraw
RDSSHD_MRMAKEFSARGS?=	-s 20m \
		-o rdroot,minfree=0,bsize=4096,fsize=512,density=4096

Programs and files

Stock installer programs remain in instbin. OpenSSH is built through its normal Makefiles as separate static PIE executables. Zlib is disabled. The ssh(1) client is not included.

Ramdisk additionsraw
#	$OpenBSD$

# bsdsshd.rd overlay.
MKDIR	usr/libexec
MKDIR	etc/ssh
MKDIR	root
MKDIR	root/.ssh

COPY	${OBJDIR}/../stage/sshd_config			etc/ssh/sshd_config
SPECIAL test ! -s ${OBJDIR}/../stage/ssh_host_ed25519_key || install -c -m 600 -o root -g wheel ${OBJDIR}/../stage/ssh_host_ed25519_key etc/ssh/ssh_host_ed25519_key
COPY	${OBJDIR}/../stage/auto_install.conf		auto_install.conf
SCRIPT	${OBJDIR}/../stage/dot.profile			.profile
SCRIPT	${OBJDIR}/../stage/install.sub			install.sub
SPECIAL	chmod 755 install.sub

COPY	${OBJDIR}/../stage/master.passwd		etc/master.passwd
COPY	${OBJDIR}/../stage/group			etc/group
SPECIAL	pwd_mkdb -p -d etc master.passwd; rm etc/master.passwd

COPY	${CURDIR}/rdsshd/root.profile			root/.profile
COPY	${OBJDIR}/../stage/authorized_keys		root/.ssh/authorized_keys
SPECIAL	chmod 700 root root/.ssh; chmod 600 root/.ssh/authorized_keys

SPECIAL	cd dev; sh MAKEDEV pty0 ptm

COPY	${OBJDIR}/../stage/bin/sshd			usr/sbin/sshd
SPECIAL	chmod 511 usr/sbin/sshd
COPY	${OBJDIR}/../stage/bin/ssh-keygen		usr/bin/ssh-keygen
SPECIAL	chmod 555 usr/bin/ssh-keygen
COPY	${OBJDIR}/../stage/bin/sshd-session		usr/libexec/sshd-session
SPECIAL	chmod 511 usr/libexec/sshd-session
COPY	${OBJDIR}/../stage/bin/sshd-auth		usr/libexec/sshd-auth
SPECIAL	chmod 511 usr/libexec/sshd-auth

COPY	${CURDIR}/../../../etc/etc.amd64/login.conf	etc/login.conf
Ramdisk additions1.3 KiBraw

The overlay adds sshd configuration, authorized keys, the root profile, login classes, early network answers, the sshd account, and pty devices. Root retains stock instbin's -sh argv link. The root profile leaves sh mode, sets the installer environment, and selects TERM=vt220. The sshd Port directive is substituted only in private staging.

Root profileraw
set +o sh
export VNAME=$(sysctl -n kern.osrelease)
export VERSION="${VNAME%.*}${VNAME#*.}"
export ARCH=$(sysctl -n hw.machine)
export OBSD="OpenBSD/$ARCH $VNAME"
PATH=/sbin:/bin:/usr/bin:/usr/sbin:/
export PATH
TERM=vt220
export TERM
umask 022
set -o emacs
PS1='rd# '
export PS1
echo
echo "ramdisk sshd is running."
echo "Run install to continue."

Overlay patches

Patches apply only to private copies of stock files. patch(1) runs non-interactively with zero fuzz. Context drift fails before a generated file is replaced.

Installer integrationraw
--- install.sub.tmp
+++ install.sub.tmp
@@ -1377,7 +1377,10 @@
 		ask_until "$_q (name, lladdr, '?', or 'done')" \
 		    ${_p:-$( (get_ifs netboot; get_ifs) | sed q )}
 
-		[[ $resp == done ]] && break
+		if [[ $resp == done ]]; then
+			NIFS=$(ls -1 /tmp/i/hostname.* 2>/dev/null | grep -c ^)
+			break
+		fi
 		[[ $resp == '?'  ]] && continue
 
 		# Quote $resp to prevent user from confusing isin() by
@@ -3170,6 +3173,21 @@
 	echo "\nConfiguring the root disk $ROOTDISK...\n"
 }
 
+rdsshd_start() {
+	local _pid
+	if [[ -s /var/run/sshd.pid ]]; then
+		_pid=$(</var/run/sshd.pid)
+		[[ $_pid == +([0-9]) ]] && (( _pid > 1 )) && \
+		    kill -0 "$_pid" 2>/dev/null && return 0
+	fi
+	if [[ ! -s /etc/ssh/ssh_host_ed25519_key ]]; then
+		/usr/bin/ssh-keygen -q -t ed25519 -N "" \
+		    -f /etc/ssh/ssh_host_ed25519_key || return 1
+	fi
+	/usr/sbin/sshd -t -f /etc/ssh/sshd_config || return 1
+	/usr/sbin/sshd -f /etc/ssh/sshd_config || return 1
+}
+
 do_install() {
 	local _rootkey _rootpass
 
@@ -3190,7 +3208,20 @@
 
 	# Configure the network.
 	donetconfig
+	if $AI && [[ $AI_RESPFILE == /auto_install.conf ]]; then
+		start_cgiinfo
+		wait_cgiinfo
+		rdsshd_start || err_exit "Could not start ramdisk sshd."
+		>/var/run/rdsshd.ready
+		cat <<__EOT
 
+ramdisk sshd is running.
+Connect as root with the matching key.
+Run: install
+__EOT
+		exit 0
+	fi
+
 	# Fetch list of mirror servers and installer choices from previous runs.
 	start_cgiinfo
 
--- dot.profile.tmp
+++ dot.profile.tmp
@@ -130,6 +130,11 @@
 		if $timeout; then
 			timeout=false
 			echo
+			if [[ -f /auto_install.conf ]]; then
+				/install -af /auto_install.conf
+				[[ -f /var/run/rdsshd.ready ]] && break
+				continue
+			fi
 			REPLY=a
 		else
 			# User has made a choice; stop the read timeout.
Installer integration1.7 KiBraw
Locked profile pathraw
--- dot.profile.tmp
+++ dot.profile.tmp
@@ -87,6 +87,15 @@
 	[[ -x /sbin/dhcpleased ]] && /sbin/dhcpleased 2>/dev/null
 	[[ -x /sbin/slaacd ]] && /sbin/slaacd 2>/dev/null
 
+	while [[ ! -f /var/run/rdsshd.ready ]]; do
+		/install -af /auto_install.conf
+		sleep 1
+	done
+
+	while :; do
+		sleep 3600
+	done
+
 	# Set up some sane tty defaults.
 	echo 'erase ^?, werase ^W, kill ^U, intr ^C, status ^T'
 	stty newcrt werase ^W intr ^C kill ^U erase ^? status ^T
Private init behaviorraw
--- init.c.tmp
+++ init.c.tmp
@@ -517,7 +517,6 @@
 		/*
 		 * Start the single user session.
 		 */
-		setctty(_PATH_CONSOLE);
 
 #ifdef SECURE
 		/*
@@ -651,8 +650,7 @@
 		}
 	}
 
-	runcom_mode = FASTBOOT;
-	return runcom;
+	return single_user;
 }
 
 /*

Images

Embedded rdroot
20 MiB.
Outer miniroot
32768 512-byte blocks; 16 MiB by default.

The miniroot stores compressed bsdsshd.gz as /bsd. It does not store uncompressed bsdsshd.rd. Example amd64 artifacts:

boot                    87 KiB
bsdsshd.gz             9.0 MiB
bsdsshd.rd            27.2 MiB
bsdsshd.strip         26.7 MiB
minirootXX_sshd.img   16.0 MiB

A private static init increases the locked image size.

SSHD

Authentication policy

sshd permits root public-key authentication only. Forwarding, interactive authentication, passwords, and compression are disabled. Static PIE executables retain normal OpenBSD OpenSSH compiler and linker protections.

sshd_configraw
Port ${RDSSHD_PORT}
HostKey /etc/ssh/ssh_host_ed25519_key
AllowUsers root
PermitRootLogin prohibit-password
AuthorizedKeysFile .ssh/authorized_keys
PubkeyAuthentication yes
AuthenticationMethods publickey
PasswordAuthentication no
KbdInteractiveAuthentication no
Compression no
PermitUserRC no
PrintMotd no
PrintLastLog no
DisableForwarding yes

Authorized keys

RDSSHD_AUTHORIZED_KEYS is mandatory. Blank and comment lines are removed. Every retained line must pass ssh-keygen(1) public-key validation.

authorized_keys validationraw
	sed -e '/^[	 ]*$$/d' -e '/^[	 ]*#/d' \
	    < "${RDSSHD_AUTHORIZED_KEYS}" > ${RDSSHD_STAGE}/authorized_keys
	test -s ${RDSSHD_STAGE}/authorized_keys
	@_n=0; while IFS= read -r _key; do \
		_n=$$((_n + 1)); \
		if ! printf '%s\n' "$$_key" | \
		    ${RDSSHD_STAGE}/bin/ssh-keygen -l -f - >/dev/null 2>&1; then \
			echo "invalid public key on line $$_n of RDSSHD_AUTHORIZED_KEYS" >&2; \
			exit 1; \
		fi; \
	done < ${RDSSHD_STAGE}/authorized_keys

Host key

No host key is embedded by default. First boot generates /etc/ssh/ssh_host_ed25519_key. Normal installer CGI fetches call feed_random first. An unreachable fetch delays sshd until the normal CGI timeout.

RDSSHD_HOST_KEY may specify an unencrypted Ed25519 private key. The build validates it and installs it with mode 0600. The newly built sshd validates its configuration with the supplied key or a temporary test key. Tests remain below the object directory and are removed. Build-host ssh configuration and keys are not used or changed.

An embedded key is recoverable from private objects, kernels, images, and release copies. Key-bearing kernel and media artifacts use mode 0600.

Startup

sshd starts after donetconfig. A live numeric PID greater than one in /var/run/sshd.pid suppresses restart. The ramdisk has no process-inspection utility, so this verifies liveness only.

install.sub creates /var/run/rdsshd.ready only after sshd starts. Profiles use this marker because installer exit status does not establish readiness.

sshd startupraw
rdsshd_start() {
	local _pid
	if [[ -s /var/run/sshd.pid ]]; then
		_pid=$(</var/run/sshd.pid)
		[[ $_pid == +([0-9]) ]] && (( _pid > 1 )) && \
		    kill -0 "$_pid" 2>/dev/null && return 0
	fi
	if [[ ! -s /etc/ssh/ssh_host_ed25519_key ]]; then
		/usr/bin/ssh-keygen -q -t ed25519 -N "" \
		    -f /etc/ssh/ssh_host_ed25519_key || return 1
	fi
	/usr/sbin/sshd -t -f /etc/ssh/sshd_config || return 1
	/usr/sbin/sshd -f /etc/ssh/sshd_config || return 1
}

donetconfig
if $AI && [[ $AI_RESPFILE == /auto_install.conf ]]; then
	start_cgiinfo
	wait_cgiinfo
	rdsshd_start || err_exit "Could not start ramdisk sshd."
	>/var/run/rdsshd.ready
	cat <<__EOT

ramdisk sshd is running.
Connect as root with the matching key.
Run: install
__EOT
	exit 0
fi

CONSOLE AND INSTALLER

Network bootstrap

The initial profile invokes install -af with early network answers. Static IPv4 configuration includes a netmask and default route. autoconf, dhcp, and none omit them.

Static IPv4 auto_install.confraw
System hostname = ${RDSSHD_AI_HOSTNAME}
Network interface to configure = ${RDSSHD_AI_IF}
IPv4 address for ${RDSSHD_AI_IF} = ${RDSSHD_AI_IPV4}
Netmask for ${RDSSHD_AI_IF} = ${RDSSHD_AI_NETMASK}
Default IPv4 route = ${RDSSHD_AI_ROUTE}
IPv6 address for ${RDSSHD_AI_IF} = ${RDSSHD_AI_IPV6}
Network interface to configure = done
DNS domain name = ${RDSSHD_AI_DOMAIN}
DNS nameservers = ${RDSSHD_AI_DNS}

Default console

Automatic setup starts after the normal installer-menu timeout unless a local operator selects another action. The profile returns to the local ramdisk shell after sshd starts.

Default profile pathraw
if [[ -f /auto_install.conf ]]; then
	/install -af /auto_install.conf
	[[ -f /var/run/rdsshd.ready ]] && break
	continue
fi

Locked console

With RDSSHD_CONSOLE_LOCK=yes, a private static init replaces /sbin/init only in the enhanced ramdisk. It is built through the stock distrib/special/init Makefile using the dedicated wrapper and stock pathnames.h. Patched source and objects stay private.

Private init wrapperraw
#	$OpenBSD$

.PATH: ${RDSSHD_INITSRCDIR}
CPPFLAGS+=	-I${.CURDIR}/../../../sbin/init
.include "${.CURDIR}/Makefile"

The bootstrap child retains inherited /dev/null descriptors. It never acquires /dev/console as a controlling terminal. The profile branches before terminal setup, retries setup until the ready marker exists, then sleeps. It presents no menu or shell. Init restarts it after exit. The default uses stock init from instbin.

Locked profile pathraw
while [[ ! -f /var/run/rdsshd.ready ]]; do
	/install -af /auto_install.conf
	sleep 1
done

while :; do
	sleep 3600
done

Remote installer

After ssh login, run install. At network prompts, select done to retain the active configuration. Reconfiguration can drop the ssh session.

SECURITY

Console locking removes the interactive local installer userland. The boot-loader and kernel consoles remain active. A local operator can still change early boot state, reset, halt, or deny remote access.

RDSSHD_KERNEL_STACK_PROTECTOR=no deliberately retains NO_PROPOLICE. This may reduce image size and weakens mitigation of kernel stack corruption.

Protect an embedded host key, the object tree, every key-bearing image, and installed copies. Use a distinct host key for each machine identity.

BUILD

Base build

Extract src.tar.gz and sys.tar.gz. Apply the patch. The configured BSDOBJDIR root, normally /usr/obj, must already exist.

Buildraw
cd /usr/src
patch -p1 < /root/bsdsshd.rd.patch

cd /usr/src/distrib/amd64/ramdisk_cd/rdsshd
make -f Makefile.rdsshd obj
make -f Makefile.rdsshd rdsshd \
    RDSSHD_AUTHORIZED_KEYS=/root/id_ed25519.pub

Run obj separately. OpenBSD make selects .OBJDIR at startup and rejects a combined obj rdsshd invocation. No top-level /usr/src make obj is required.

Console lock

Remove local installer sessionraw
make -f Makefile.rdsshd rdsshd \
    RDSSHD_AUTHORIZED_KEYS=/root/id_ed25519.pub \
    RDSSHD_CONSOLE_LOCK=yes

Kernel without stack protection

Retain NO_PROPOLICEraw
make -f Makefile.rdsshd rdsshd \
    RDSSHD_AUTHORIZED_KEYS=/root/id_ed25519.pub \
    RDSSHD_KERNEL_STACK_PROTECTOR=no

Embedded host key

Generate and embed host keyraw
ssh-keygen -q -t ed25519 -N '' -f /root/rdsshd_host_ed25519_key
make -f Makefile.rdsshd rdsshd \
    RDSSHD_AUTHORIZED_KEYS=/root/id_ed25519.pub \
    RDSSHD_HOST_KEY=/root/rdsshd_host_ed25519_key

Use a distinct host key per machine identity. Protect its source, object tree, and all resulting images.

Outputs

/usr/src/distrib/amd64/ramdisk_cd/rdsshd/obj/bsdsshd.rd
/usr/src/distrib/amd64/ramdisk_cd/rdsshd/obj/minirootXX_sshd.img

REBUILD AND CLEAN

Option or key changes

Repeat rdsshd. Normal dependency rules reuse current stock instbin components, OpenSSH, private init, and the selected kernel. Staging, ramdisk, and media are regenerated.

Rebuild options or keysraw
make -f Makefile.rdsshd rdsshd \
    RDSSHD_AUTHORIZED_KEYS=/root/id_ed25519.pub

Source or toolchain changes

Clean both ownership domains after source, compiler, flag, or Makefile changes. A top-level cleandir may replace the three stock cleans. The wrapper clean never removes stock objects.

Rebuild changed sourcesraw
cd /usr/src/distrib/amd64/ramdisk_cd
make cleandir
cd /usr/src/distrib/special
make cleandir
cd /usr/src/lib
make cleandir

cd /usr/src/distrib/amd64/ramdisk_cd/rdsshd
make -f Makefile.rdsshd cleandir
make -f Makefile.rdsshd obj
make -f Makefile.rdsshd rdsshd \
    RDSSHD_AUTHORIZED_KEYS=/root/id_ed25519.pub

Clean ramdisk_cd before retrying. Its instbin.map and reduced archives may be incomplete.

Clean stock instbin stateraw
cd /usr/src/distrib/amd64/ramdisk_cd
make cleandir

Interrupted media build

Release private media stateraw
make -f Makefile.rdsshd unconfig-rdsshd

Remove patch

Prepare private state before reversal. The target prints the recorded object path. patch(1) removes added files but leaves their empty parent directory. rmdir refuses non-empty directories.

Clean and reverse patchraw
cd /usr/src/distrib/amd64/ramdisk_cd/rdsshd
make -f Makefile.rdsshd prepare-unpatch-rdsshd

cd /usr/src
patch -R -p1 < /root/bsdsshd.rd.patch
# Replace /usr/obj below if prepare-unpatch-rdsshd printed another path.
rmdir /usr/obj/distrib/amd64/ramdisk_cd/rdsshd
rmdir /usr/src/distrib/amd64/ramdisk_cd/rdsshd

OPTIONS

RDSSHD_AUTHORIZED_KEYS
Required root authorized-keys source.
RDSSHD_HOST_KEY
Optional unencrypted Ed25519 host private key. An empty value generates a key at each boot. Default: empty.
RDSSHD_CONSOLE_LOCK
yes removes the local installer session. no retains the menu and shell. Default: no.
RDSSHD_KERNEL_STACK_PROTECTOR
yes removes inherited NO_PROPOLICE. no retains it. Default: yes.
RDSSHD_PORT
sshd port. Default: 22.
RDSSHD_AI_IF
Network interface. Default: em0.
RDSSHD_AI_HOSTNAME
Hostname. Default: rdinstall.
RDSSHD_AI_IPV4
IPv4 address or installer keyword. Default: autoconf.
RDSSHD_AI_NETMASK
Static IPv4 netmask. Default: 255.255.255.0.
RDSSHD_AI_ROUTE
Static IPv4 default route. Default: none.
RDSSHD_AI_IPV6
IPv6 address or installer keyword. Default: none.
RDSSHD_AI_DOMAIN
DNS domain. Default: my.domain.
RDSSHD_AI_DNS
DNS nameservers. Default: none.
RDSSHD_FSSIZE
Outer miniroot size in 512-byte blocks. Default: 32768 (16 MiB).

Boolean values are case-insensitive. Values other than yes and no are rejected.

Defaults and validationraw
RDSSHD_AUTHORIZED_KEYS?=
RDSSHD_HOST_KEY?=
RDSSHD_CONSOLE_LOCK?=	no
RDSSHD_KERNEL_STACK_PROTECTOR?=	yes
RDSSHD_PORT?=	22
RDSSHD_AI_IF?=	em0
RDSSHD_AI_HOSTNAME?=	rdinstall
RDSSHD_AI_IPV4?=	autoconf
RDSSHD_AI_NETMASK?=	255.255.255.0
RDSSHD_AI_ROUTE?=	none
RDSSHD_AI_IPV6?=	none
RDSSHD_AI_DOMAIN?=	my.domain
RDSSHD_AI_DNS?=	none
RDSSHD_FSSIZE?=	32768

rdsshd-check:
	@if [ -z "${RDSSHD_AUTHORIZED_KEYS}" ]; then \
		echo "set RDSSHD_AUTHORIZED_KEYS to a public key file" >&2; \
		exit 1; \
	fi
	@case "${RDSSHD_CONSOLE_LOCK:L}" in \
	yes|no) ;; \
	*) echo "RDSSHD_CONSOLE_LOCK must be yes or no" >&2; exit 1;; \
	esac
	@case "${RDSSHD_KERNEL_STACK_PROTECTOR:L}" in \
	yes|no) ;; \
	*) echo "RDSSHD_KERNEL_STACK_PROTECTOR must be yes or no" >&2; exit 1;; \
	esac
Defaults and validation759 Braw

USE

Boot either enhanced image. Connect as root after the configured address accepts ssh. Start the interactive installer remotely.

Remote installraw
ssh -i /path/to/private_key root@host
# Perform the required storage setup.
# Enter "done" at the network prompt to preserve network configuration.
install

A generated host key uses trust on first use and changes at every boot. For stable authenticated identity, supply RDSSHD_HOST_KEY and verify its fingerprint or known_hosts entry.

With console locking, the display retains boot-loader and kernel output but presents no installer menu or shell.

BOOTING

Secure Boot

If access to boot configuration is not available, it is prudent to check using other methods. For example, presence of the /sys/firmware/efi directory on GNU/linux systems, or using tools such as mokutil to check Secure Boot status.

Systems which enforce Secure Boot present significant difficulties due to the lack of a Microsoft-trusted Secure Boot chain.

Systems using EFI (and not enforcing Secure Boot) shouldn't present a problem when using the minirootXX_sshd.img, which provides BOOTX64.EFI. Some additional checks should be made if attempting to boot bsdsshd.rd using an existing boot loader such as GRUB.

BIOS/CSM systems should "Just Work™".

Non-destructive

The bsdsshd.rd kernel is an ELF executable and may be booted directly from an existing bootloader such as GRUB, or network booting etc.

To configure debian for a one-shot boot which will revert to the previous default GRUB entry on subsequent reboot (if not overwritten).

Copy ramdisk kernelraw
# Use the uncompressed kernel; GRUB's gzio module was not tested.
cp bsdsshd.rd /boot/bsdsshd.rd
/etc/grub.d/40_customraw
menuentry "OpenBSD bsdsshd.rd" {
        insmod part_gpt
        insmod ext2
        insmod bsd
        # Replace UUID with the /boot filesystem UUID.
        search --no-floppy --fs-uuid --set=root UUID
        kopenbsd /bsdsshd.rd
}
/etc/default/grubraw
GRUB_DEFAULT=saved
Select one-shot bootraw
update-grub
grub-reboot "OpenBSD bsdsshd.rd"
grub-editenv list

# Expected output:
# next_entry=OpenBSD bsdsshd.rd

sync
reboot

Destructive

Writing the minirootXX_sshd.img to the beginning of the first bootable disk is destructive and may be blocked by restrictions imposed by the existing OS.

dd(1) destructive writeraw
dd if=./miniroot79_sshd.img of=/dev/sda bs=512

sync
reboot

Message sent

X

Thank you. Your message has been sent.