Project Notes
arachnopress
zero-JS, cookie-free, monolithic static site generator
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(/</, "\\<")
gsub(/>/, "\\>")
print
}' "$@"
}
html_escape_attr() {
printf '%s\n' "$1" | awk '{
gsub(/&/, "\\&")
gsub(/</, "\\<")
gsub(/>/, "\\>")
gsub(/"/, "\\"")
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(/&/, "\\&", line)
gsub(/</, "\\<", line)
gsub(/>/, "\\>", 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(/"|"/, "\"", s)
gsub(/'|'/, "\047", s)
gsub(/<|</, "<", s)
gsub(/>|>/, ">", s)
gsub(/&|&/, "\\&", 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(/"|"/, "\"", s)
gsub(/'|'/, "\047", s)
gsub(/<|</, "<", s)
gsub(/>|>/, ">", s)
gsub(/&|&/, "\\&", 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/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/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
"&" "&"
"<" "<"
">" ">"
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(/"|"/, "\"", s)
gsub(/'|'/, "\047", s)
gsub(/<|</, "<", s)
gsub(/>|>/, ">", s)
gsub(/&|&/, "\\&", 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;
}
}
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
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(/&/, "\\&")
gsub(/</, "\\<")
gsub(/>/, "\\>")
print
}' "$@"
}
html_escape_attr() {
printf '%s\n' "$1" | awk '{
gsub(/&/, "\\&")
gsub(/</, "\\<")
gsub(/>/, "\\>")
gsub(/"/, "\\"")
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
}
Footer behaviour
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
}
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'
}
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
}
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(/&/, "\\&", line)
gsub(/</, "\\<", line)
gsub(/>/, "\\>", 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"
}