aboutsummaryrefslogtreecommitdiffstats
path: root/build.sh
diff options
context:
space:
mode:
authorAhmed <git@gumx.cc>2026-06-01 22:19:27 +0300
committerAhmed <git@gumx.cc>2026-06-01 22:19:27 +0300
commitae72b8f9976a1c0cca66ff4cb31eadf311c677e7 (patch)
tree532e48d085bd0ea48265f2bd262df8856dd8340f /build.sh
init: moved to own site
Diffstat (limited to 'build.sh')
-rwxr-xr-xbuild.sh914
1 files changed, 914 insertions, 0 deletions
diff --git a/build.sh b/build.sh
new file mode 100755
index 0000000..b5a9a42
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,914 @@
+#!/bin/bash
+# build.sh - gumx.cc static site generator
+# Licensed under the MIT License (see LICENSES/MIT)
+
+# Must be run directly, not sourced
+[[ "$(basename "${0}")" == "build.sh" ]] || { echo "Do not source build.sh"; exit 1; }
+
+set -euo pipefail
+
+# ---------------------------------------------------------------------------
+# Source mo mustache renderer
+# ---------------------------------------------------------------------------
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+if [[ ! -f "${SCRIPT_DIR}/mo" ]]; then
+ echo "[error] mo not found. Run: curl -sL https://raw.githubusercontent.com/tests-always-included/mo/master/mo -o mo && chmod +x mo" >&2
+ exit 1
+fi
+# shellcheck source=mo
+. "${SCRIPT_DIR}/mo"
+
+# ---------------------------------------------------------------------------
+# Defaults
+# ---------------------------------------------------------------------------
+VERBOSE=false
+DRAFTS=false
+FORCE=false
+DO_CLEAN=false
+DO_PREP=false
+DO_SERVE=false
+
+LIST_COUNT=5
+
+TMPDIR_BUILD="$(mktemp -d)"
+
+# ---------------------------------------------------------------------------
+# Cleanup trap
+# ---------------------------------------------------------------------------
+cleanup() {
+ rm -rf "${TMPDIR_BUILD}"
+}
+trap cleanup EXIT INT TERM
+
+# ---------------------------------------------------------------------------
+# CLI parsing
+# ---------------------------------------------------------------------------
+usage() {
+ cat <<EOF
+Usage: $(basename "$0") [OPTIONS]
+
+Options:
+ -h, --help Show this help message
+ -v, --verbose Verbose output
+ -d, --drafts Include undated posts (marked [DRAFT])
+ -f, --force Force rebuild of all pages
+ -c, --clean Remove output/ and exit
+ -p, --prep Optimize untracked images and fix markdown trailing newlines
+ -s, --serve After building, serve output/ on port 8080 with darkhttpd
+EOF
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -h|--help) usage; exit 0 ;;
+ -v|--verbose) VERBOSE=true ;;
+ -d|--drafts) DRAFTS=true ;;
+ -f|--force) FORCE=true ;;
+ -c|--clean) DO_CLEAN=true ;;
+ -p|--prep) DO_PREP=true ;;
+ -s|--serve) DO_SERVE=true ;;
+ *) echo "[error] Unknown option: $1" >&2; usage; exit 1 ;;
+ esac
+ shift
+done
+
+# ---------------------------------------------------------------------------
+# --clean
+# ---------------------------------------------------------------------------
+if [[ "${DO_CLEAN}" == true ]]; then
+ rm -rf output
+ echo "[clean] output/ removed"
+ exit 0
+fi
+
+# ---------------------------------------------------------------------------
+# --prep (absorbed from prep.sh)
+# ---------------------------------------------------------------------------
+if [[ "${DO_PREP}" == true ]]; then
+ echo "[prep] Optimizing untracked images..."
+ for img in $(git ls-files -o images/ 2>/dev/null); do
+ if identify "${img}" > /dev/null 2>&1; then
+ mogrify -strip -resize 800x "${img}"
+ echo "[prep:processed] ${img}"
+ else
+ echo "[prep:ignored] ${img}"
+ fi
+ done
+
+ echo "[prep] Adding trailing newlines to .md files..."
+ local _prep_list
+ _prep_list="$(mktemp)"
+ find . -name '*.md' -not -path './.git/*' -not -path './mo/*' -print0 > "${_prep_list}"
+ while IFS= read -r -d '' f; do
+ if [[ -s "${f}" ]]; then
+ last_char="$(tail -c1 "${f}")"
+ if [[ "${last_char}" != "" ]]; then
+ printf '\n' >> "${f}"
+ echo "[prep:newline] ${f}"
+ fi
+ fi
+ done < "${_prep_list}"
+ rm -f "${_prep_list}"
+fi
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+log() {
+ if [[ "${VERBOSE}" == true ]]; then echo "$*"; fi
+}
+
+# needs_rebuild src dst [template]
+# Returns 0 (true / do rebuild) if dst is missing or older than src/template
+# Returns 1 (false / skip) if dst is up-to-date and --force is not set
+needs_rebuild() {
+ local src="$1" dst="$2" tmpl="${3:-}"
+ [[ "${FORCE}" == true ]] && return 0
+ [[ ! -f "${dst}" ]] && return 0
+ [[ "${src}" -nt "${dst}" ]] && return 0
+ [[ -n "${tmpl}" && -f "${tmpl}" && "${tmpl}" -nt "${dst}" ]] && return 0
+ return 1
+}
+
+# render_page template outfile
+# All variables exported before calling this function are available.
+render_page() {
+ local template="$1"
+ local outfile="$2"
+ mkdir -p "$(dirname "${outfile}")"
+ # Copy template into partials/ so mo resolves {{> partial}} relative to that dir,
+ # then remove the copy. Avoids any /dev/stdin or /dev/fd dependency.
+ local _tmpl_name="${template##templates/}"
+ cp "${template}" "templates/partials/${_tmpl_name}"
+ (cd templates/partials && mo "${_tmpl_name}") > "${outfile}"
+ rm -f "templates/partials/${_tmpl_name}"
+}
+
+# build_breadcrumbs path -> sets BREADCRUMBS variable
+build_breadcrumbs() {
+ local path="$1"
+ BREADCRUMBS=""
+ local components="${path}"
+ while [[ "${components}" != "." ]]; do
+ BREADCRUMBS=" / <a href=\"/${components}\">$(basename "${components}")</a>${BREADCRUMBS}"
+ components="$(dirname "${components}")"
+ done
+}
+
+# get_frontmatter key file
+get_frontmatter() {
+ local key="$1" file="$2"
+ # lowdown extracts YAML frontmatter fields
+ lowdown -X "${key}" "${file}" 2>/dev/null || true
+}
+
+# check_has_code content_html -> sets HAS_CODE ("true" or "")
+check_has_code() {
+ local html="$1"
+ if echo "${html}" | grep -qE '<pre><code|<code class='; then
+ HAS_CODE="true"
+ else
+ HAS_CODE=""
+ fi
+}
+
+# render_content_page src_md out_html template_file [header_extra] [lang]
+render_content_page() {
+ local src_md="$1"
+ local out_html="$2"
+ local template_file="$3"
+ local header_extra="${4:-}"
+ local lang="${5:-en}"
+
+ if ! needs_rebuild "${src_md}" "${out_html}" "${template_file}"; then
+ log "[skip] ${out_html}"
+ return
+ fi
+
+ local page_title
+ page_title="$(get_frontmatter title "${src_md}")"
+
+ local content
+ content="$(lowdown "${src_md}")"
+
+ check_has_code "${content}"
+
+ # Derive output path without "output/" prefix and without "/index.html" suffix
+ local rel_path
+ rel_path="${out_html#output/}" # e.g. blog/hello/index.html
+ rel_path="${rel_path%/index.html}" # e.g. blog/hello
+
+ build_breadcrumbs "${rel_path}"
+
+ # Export variables for mo
+ export PAGE_TITLE="${page_title}"
+ export CONTENT="${content}"
+ export BREADCRUMBS
+ export HEADER_EXTRA="${header_extra}"
+ export META_DATE="${header_extra}"
+ export META_DESCRIPTION=""
+ export HAS_CODE
+ export LANG="${lang}"
+ export DIR=""
+ [[ "${lang}" == "ar" ]] && export DIR="rtl"
+ export IS_HOME=""
+ export ARCHIVE_TITLE=""
+
+ render_page "${template_file}" "${out_html}"
+ echo "[built] ${out_html}"
+}
+
+# ---------------------------------------------------------------------------
+# process_content dir template_type archive_title date_required
+# Handles blog, talks, code, recipes
+# ---------------------------------------------------------------------------
+process_content() {
+ local dir="$1"
+ local template_type="$2"
+ local archive_title="$3"
+ local date_required="$4" # "true" or "false"
+
+ if [[ ! -d "${dir}" ]]; then
+ log "[info] ${dir}/ directory not found, skipping"
+ return
+ fi
+
+ local list_file="${TMPDIR_BUILD}/${dir}.list.md"
+ : > "${list_file}"
+
+ local _find_list="${TMPDIR_BUILD}/${dir}.find.list"
+ find "${dir}" -mindepth 1 -maxdepth 1 -name '*.md' -not -name '.*' -print0 | sort -z > "${_find_list}"
+
+ while IFS= read -r -d '' src; do
+ local filename
+ filename="$(basename "${src}")"
+ local title date lang event header_extra
+ title="$(get_frontmatter title "${src}")"
+ date="$(get_frontmatter date "${src}")"
+ lang="$(get_frontmatter lang "${src}")"
+ event="$(get_frontmatter event "${src}")"
+
+ # Derive URL path
+ local rel="${src#${dir}/}" # e.g. hello.md
+ local slug="${rel%.md}" # e.g. hello
+ local url_path="/${dir}/${slug}"
+ local out_dir="output/${dir}/${slug}"
+ local out_html="${out_dir}/index.html"
+
+ # Draft handling
+ if [[ "${date_required}" == "true" && -z "${date}" ]]; then
+ if [[ "${DRAFTS}" == true ]]; then
+ title="[DRAFT] ${title}"
+ printf -- '- [DRAFT] [%s](%s)\n' "${title}" "${url_path}" >> "${list_file}"
+ echo "[draft] ${src}"
+ else
+ log "[skipped-draft] ${src}"
+ continue
+ fi
+ else
+ if [[ -n "${date}" ]]; then
+ if [[ -n "${event}" ]]; then
+ printf -- '- [%s] [%s](%s) @ %s\n' "${date}" "${title}" "${url_path}" "${event}" >> "${list_file}"
+ else
+ printf -- '- [%s] [%s](%s)\n' "${date}" "${title}" "${url_path}" >> "${list_file}"
+ fi
+ else
+ printf -- '- [%s](%s)\n' "${title}" "${url_path}" >> "${list_file}"
+ fi
+ fi
+
+ # Build the page
+ header_extra="${date}"
+ [[ -n "${event}" ]] && header_extra="${date}${date:+ — }${event}"
+
+ local tmpl="templates/${template_type}.html"
+ render_content_page "${src}" "${out_html}" "${tmpl}" "${header_extra}" "${lang:-en}"
+ echo "[${template_type}] ${url_path}"
+
+ done < "${_find_list}"
+
+ # Build archive page
+ local archive_md="${TMPDIR_BUILD}/${dir}-archive.md"
+ printf '# %s\n\n' "${archive_title}" > "${archive_md}"
+ sort -r "${list_file}" >> "${archive_md}"
+
+ local archive_html="output/${dir}/index.html"
+ export ARCHIVE_TITLE="${archive_title}"
+ export PAGE_TITLE="${archive_title}"
+ export CONTENT
+ CONTENT="$(lowdown "${archive_md}")"
+ export BREADCRUMBS=" / <a href=\"/${dir}\">${dir}</a>"
+ export HEADER_EXTRA=""
+ export META_DATE=""
+ export META_DESCRIPTION=""
+ export HAS_CODE=""
+ export LANG="en"
+ export DIR=""
+ export IS_HOME=""
+
+ if needs_rebuild "${list_file}" "${archive_html}" "templates/archive.html"; then
+ render_page "templates/archive.html" "${archive_html}"
+ echo "[archive] ${archive_html}"
+ else
+ log "[skip] ${archive_html}"
+ fi
+}
+
+# ---------------------------------------------------------------------------
+# process_projects
+# ---------------------------------------------------------------------------
+process_projects() {
+ local list_file="${TMPDIR_BUILD}/projects.list.md"
+ : > "${list_file}"
+
+ if [[ ! -d "projects" ]]; then
+ echo "[warning] projects/ directory not found"
+ return
+ fi
+
+ local _proj_list="${TMPDIR_BUILD}/projects.find.list"
+ find "projects" -mindepth 1 -maxdepth 1 -name '*.md' -not -name '.*' -print0 | sort -z > "${_proj_list}"
+
+ while IFS= read -r -d '' src; do
+ local title url demo sources
+ title="$(get_frontmatter title "${src}")"
+ url="$(get_frontmatter url "${src}")"
+ demo="$(get_frontmatter demo "${src}")"
+ sources_raw="$(get_frontmatter sources "${src}")"
+
+ local slug
+ slug="$(basename "${src}" .md)"
+ local url_path="/projects/${slug}"
+ local out_html="output/projects/${slug}/index.html"
+ local tmpl="templates/project.html"
+
+ # Build sources HTML links from "name:url,name:url" pairs
+ local sources_html=""
+ if [[ -n "${sources_raw}" ]]; then
+ IFS=',' read -ra pairs <<< "${sources_raw}"
+ for pair in "${pairs[@]}"; do
+ local sname surl
+ sname="${pair%%:*}"
+ surl="${pair#*:}"
+ # Handle URLs with double-colon (http://)
+ # The format is name:scheme://rest — re-join after first ':'
+ surl="${pair#${sname}:}"
+ sources_html+="<a href=\"${surl}\">${sname}</a> "
+ done
+ sources_html="${sources_html% }"
+ fi
+
+ # Archive entry
+ printf -- '- [%s](%s)\n' "${title}" "${url_path}" >> "${list_file}"
+
+ if ! needs_rebuild "${src}" "${out_html}" "${tmpl}"; then
+ log "[skip] ${out_html}"
+ continue
+ fi
+
+ local content
+ content="$(lowdown "${src}")"
+ check_has_code "${content}"
+
+ build_breadcrumbs "projects/${slug}"
+
+ export PAGE_TITLE="${title}"
+ export CONTENT="${content}"
+ export BREADCRUMBS
+ export HEADER_EXTRA=""
+ export META_DATE=""
+ export META_DESCRIPTION=""
+ export HAS_CODE
+ export LANG="en"
+ export DIR=""
+ export IS_HOME=""
+ export PROJECT_URL="${url}"
+ export PROJECT_DEMO="${demo}"
+ export PROJECT_SOURCES="${sources_html}"
+ export ARCHIVE_TITLE=""
+
+ render_page "${tmpl}" "${out_html}"
+ echo "[project] ${url_path}"
+
+ done < "${_proj_list}"
+
+ # Archive
+ local archive_md="${TMPDIR_BUILD}/projects-archive.md"
+ printf '# projects\n\n' > "${archive_md}"
+ cat "${list_file}" >> "${archive_md}"
+
+ local archive_html="output/projects/index.html"
+ export ARCHIVE_TITLE="projects"
+ export PAGE_TITLE="projects"
+ CONTENT="$(lowdown "${archive_md}")"
+ export CONTENT
+ export BREADCRUMBS=" / <a href=\"/projects\">projects</a>"
+ export HEADER_EXTRA=""
+ export META_DATE=""
+ export META_DESCRIPTION=""
+ export HAS_CODE=""
+ export LANG="en"
+ export DIR=""
+ export IS_HOME=""
+ export PROJECT_URL=""
+ export PROJECT_DEMO=""
+ export PROJECT_SOURCES=""
+
+ if needs_rebuild "${list_file}" "${archive_html}" "templates/archive.html"; then
+ render_page "templates/archive.html" "${archive_html}"
+ echo "[archive] ${archive_html}"
+ else
+ log "[skip] ${archive_html}"
+ fi
+}
+
+# ---------------------------------------------------------------------------
+# process_slides
+# ---------------------------------------------------------------------------
+process_slides() {
+ if [[ ! -d "slides" ]]; then
+ log "[info] slides/ directory not found, skipping"
+ return
+ fi
+
+ local list_file="${TMPDIR_BUILD}/slides.list.md"
+ : > "${list_file}"
+
+ local _slides_list="${TMPDIR_BUILD}/slides.find.list"
+ find "slides" -mindepth 1 -maxdepth 1 -name '*.md' -not -name '.*' -print0 | sort -z > "${_slides_list}"
+
+ while IFS= read -r -d '' src; do
+ local title date theme
+ title="$(get_frontmatter title "${src}")"
+ date="$(get_frontmatter date "${src}")"
+ theme="$(get_frontmatter theme "${src}")"
+ [[ -z "${theme}" ]] && theme="black"
+
+ local slug
+ slug="$(basename "${src}" .md)"
+ local url_path="/slides/${slug}"
+ local out_html="output/slides/${slug}/index.html"
+ local tmpl="templates/slides.html"
+
+ if [[ -n "${date}" ]]; then
+ printf -- '- [%s] [%s](%s)\n' "${date}" "${title}" "${url_path}" >> "${list_file}"
+ else
+ printf -- '- [%s](%s)\n' "${title}" "${url_path}" >> "${list_file}"
+ fi
+
+ if ! needs_rebuild "${src}" "${out_html}" "${tmpl}"; then
+ log "[skip] ${out_html}"
+ continue
+ fi
+
+ # Split markdown on '---' slide separators into <section data-markdown> blocks
+ local slides_content
+ slides_content="$(awk '
+ BEGIN { in_slide=0; buf="" }
+ /^---$/ {
+ if (in_slide) {
+ print "<section data-markdown><textarea data-template>" buf "</textarea></section>"
+ buf=""
+ } else {
+ in_slide=1
+ }
+ next
+ }
+ in_slide { buf = buf "\n" $0 }
+ END {
+ if (buf != "") {
+ print "<section data-markdown><textarea data-template>" buf "</textarea></section>"
+ }
+ }
+ ' "${src}")"
+
+ mkdir -p "output/slides/${slug}"
+
+ export TITLE="${title}"
+ export THEME="${theme}"
+ export SLIDES_CONTENT="${slides_content}"
+
+ cp "templates/slides.html" "templates/partials/slides.html"
+ (cd templates/partials && mo "slides.html") > "${out_html}"
+ rm -f "templates/partials/slides.html"
+ echo "[slides] ${url_path}"
+
+ done < "${_slides_list}"
+
+ # Archive listing
+ local archive_md="${TMPDIR_BUILD}/slides-archive.md"
+ printf '# slides\n\n' > "${archive_md}"
+ sort -r "${list_file}" >> "${archive_md}"
+
+ local archive_html="output/slides/index.html"
+ export ARCHIVE_TITLE="slides"
+ export PAGE_TITLE="slides"
+ CONTENT="$(lowdown "${archive_md}")"
+ export CONTENT
+ export BREADCRUMBS=" / <a href=\"/slides\">slides</a>"
+ export HEADER_EXTRA=""
+ export META_DATE=""
+ export META_DESCRIPTION=""
+ export HAS_CODE=""
+ export LANG="en"
+ export DIR=""
+ export IS_HOME=""
+
+ if needs_rebuild "${list_file}" "${archive_html}" "templates/archive.html"; then
+ render_page "templates/archive.html" "${archive_html}"
+ echo "[archive] ${archive_html}"
+ else
+ log "[skip] ${archive_html}"
+ fi
+}
+
+# ---------------------------------------------------------------------------
+# process_garden
+# ---------------------------------------------------------------------------
+process_garden() {
+ local dir="${1:-garden}"
+ local garden_list="${TMPDIR_BUILD}/${dir}.garden.list.md"
+ : > "${garden_list}"
+
+ local _garden_find="${TMPDIR_BUILD}/${dir//\//_}.find.list"
+ find "${dir}" -mindepth 1 -maxdepth 1 -not -name '.*' -print0 | sort -z > "${_garden_find}"
+
+ while IFS= read -r -d '' node; do
+ if [[ -d "${node}" ]]; then
+ local subnode_title
+ subnode_title="$(get_frontmatter title "${node}/index.md" 2>/dev/null || true)"
+ [[ -z "${subnode_title}" ]] && subnode_title="$(basename "${node}")"
+ printf -- '- [%s/](/%s)\n' "${subnode_title}" "${node}" >> "${garden_list}"
+ process_garden "${node}"
+ else
+ if [[ "${node}" == "${node%.md}" ]]; then
+ # Non-markdown asset — copy verbatim
+ cp -a --parent "${node}" output/
+ elif [[ "$(basename "${node}")" != "index.md" ]]; then
+ local title date lang
+ title="$(get_frontmatter title "${node}")"
+ [[ -z "${title}" ]] && title="$(basename "${node}" .md)"
+ date="$(get_frontmatter date "${node}")"
+ lang="$(get_frontmatter lang "${node}")"
+
+ local rel="${node#${dir}/}"
+ local slug="${rel%.md}"
+ local url_path="/${dir}/${slug}"
+ local out_html="output/${dir}/${slug}/index.html"
+ local tmpl="templates/garden.html"
+
+ render_content_page "${node}" "${out_html}" "${tmpl}" "${date}" "${lang:-en}"
+ printf -- '- [%s](%s)\n' "${title}" "${url_path}" >> "${garden_list}"
+ garden_count=$(( garden_count + 1 ))
+ fi
+ fi
+ done < "${_garden_find}"
+
+ # Build / update the index for this directory
+ local garden_index_md="${TMPDIR_BUILD}/${dir//\//_}.index.md"
+ local garden_title
+ if [[ -f "${dir}/index.md" ]]; then
+ garden_title="$(get_frontmatter title "${dir}/index.md")"
+ # Append the listing to a copy of the index
+ cp "${dir}/index.md" "${garden_index_md}"
+ else
+ garden_title="$(basename "${dir}")"
+ printf '# %s\n\n' "${garden_title}" > "${garden_index_md}"
+ fi
+ printf '\n\n' >> "${garden_index_md}"
+ cat "${garden_list}" >> "${garden_index_md}"
+
+ local out_html="output/${dir}/index.html"
+ local tmpl="templates/garden.html"
+
+ if needs_rebuild "${garden_index_md}" "${out_html}" "${tmpl}"; then
+ local content
+ content="$(lowdown "${garden_index_md}")"
+ check_has_code "${content}"
+ build_breadcrumbs "${dir}"
+
+ export PAGE_TITLE="${garden_title}"
+ export CONTENT="${content}"
+ export BREADCRUMBS
+ export HEADER_EXTRA=""
+ export META_DATE=""
+ export META_DESCRIPTION=""
+ export HAS_CODE
+ export LANG="en"
+ export DIR=""
+ export IS_HOME=""
+ export ARCHIVE_TITLE=""
+
+ render_page "${tmpl}" "${out_html}"
+ echo "[garden] output/${dir}/index.html"
+ else
+ log "[skip] output/${dir}/index.html"
+ fi
+}
+
+# ---------------------------------------------------------------------------
+# generate_rss_feed
+# ---------------------------------------------------------------------------
+generate_rss_feed() {
+ local list_file="${TMPDIR_BUILD}/blog.list.md"
+ [[ ! -f "${list_file}" ]] && return
+
+ local out="output/feed.xml"
+ mkdir -p output
+
+ {
+ cat <<'RSSHEAD'
+<?xml version="1.0" encoding="UTF-8"?>
+<rss version="2.0">
+ <channel>
+ <title>gumx</title>
+ <link>https://gumx.cc</link>
+ <description>gumx blog</description>
+ <language>en</language>
+RSSHEAD
+
+ # Top 20 entries, sorted newest first
+ sort -r "${list_file}" | head -n 20 | while IFS= read -r line; do
+ # Line format: - [DATE] [TITLE](URL)
+ local date title url
+ date="$(echo "${line}" | grep -oP '\[\K[0-9]{4}-[0-9]{2}-[0-9]{2}(?=\])')"
+ title="$(echo "${line}" | grep -oP '\]\s+\[\K[^\]]+(?=\]\()')"
+ url="$(echo "${line}" | grep -oP '\]\(\K[^)]+(?=\))')"
+ [[ -z "${date}" || -z "${title}" || -z "${url}" ]] && continue
+
+ # RFC 2822 date from YYYY-MM-DD
+ local rfc_date
+ rfc_date="$(date -d "${date}" -R 2>/dev/null || date -jf '%Y-%m-%d' "${date}" '+%a, %d %b %Y 00:00:00 +0000' 2>/dev/null || echo "${date}")"
+
+ cat <<ITEM
+ <item>
+ <title>${title}</title>
+ <link>https://gumx.cc${url}</link>
+ <guid>https://gumx.cc${url}</guid>
+ <pubDate>${rfc_date}</pubDate>
+ </item>
+ITEM
+ done
+
+ echo " </channel>"
+ echo "</rss>"
+ } > "${out}"
+
+ echo "[rss] ${out}"
+}
+
+# generate_sitemap
+# ---------------------------------------------------------------------------
+generate_sitemap() {
+ local out="output/sitemap.xml"
+
+ {
+ echo '<?xml version="1.0" encoding="UTF-8"?>'
+ echo '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
+ find output -name 'index.html' | sort | while read -r html; do
+ local path
+ path="${html#output}" # /blog/hello/index.html
+ path="${path%index.html}" # /blog/hello/
+ echo " <url><loc>https://gumx.cc${path}</loc></url>"
+ done
+ echo '</urlset>'
+ } > "${out}"
+
+ echo "[sitemap] ${out}"
+}
+
+# ---------------------------------------------------------------------------
+# build_includes
+# ---------------------------------------------------------------------------
+build_includes() {
+ local inc_md="${TMPDIR_BUILD}/includes.md"
+ printf '# includes\n\n' > "${inc_md}"
+ for inc in $(find includes -type f ! -name '.*' | sort); do
+ printf -- '- [%s](/%s)\n' "$(basename "${inc}")" "${inc}" >> "${inc_md}"
+ done
+
+ local out_html="output/includes/index.html"
+ local tmpl="templates/base.html"
+
+ if needs_rebuild "${inc_md}" "${out_html}" "${tmpl}"; then
+ local content
+ content="$(lowdown "${inc_md}")"
+ check_has_code "${content}"
+
+ export PAGE_TITLE="includes"
+ export CONTENT="${content}"
+ export BREADCRUMBS=" / <a href=\"/includes\">includes</a>"
+ export HEADER_EXTRA=""
+ export META_DATE=""
+ export META_DESCRIPTION=""
+ export HAS_CODE
+ export LANG="en"
+ export DIR=""
+ export IS_HOME=""
+ export ARCHIVE_TITLE=""
+
+ render_page "${tmpl}" "${out_html}"
+ echo "[built] ${out_html}"
+ else
+ log "[skip] ${out_html}"
+ fi
+}
+
+# ---------------------------------------------------------------------------
+# build_license
+# ---------------------------------------------------------------------------
+build_license() {
+ local src="license.md"
+ local out_html="output/license/index.html"
+ local tmpl="templates/base.html"
+
+ if needs_rebuild "${src}" "${out_html}" "${tmpl}"; then
+ local content
+ content="$(lowdown "${src}")"
+ check_has_code "${content}"
+
+ export PAGE_TITLE="license"
+ export CONTENT="${content}"
+ export BREADCRUMBS=" / <a href=\"/license\">license</a>"
+ export HEADER_EXTRA=""
+ export META_DATE=""
+ export META_DESCRIPTION=""
+ export HAS_CODE
+ export LANG="en"
+ export DIR=""
+ export IS_HOME=""
+ export ARCHIVE_TITLE=""
+
+ render_page "${tmpl}" "${out_html}"
+ echo "[built] ${out_html}"
+ else
+ log "[skip] ${out_html}"
+ fi
+}
+
+# ---------------------------------------------------------------------------
+# build_home
+# ---------------------------------------------------------------------------
+build_home() {
+ local src="index.md"
+ local out_html="output/index.html"
+ local tmpl="templates/home.html"
+
+ # We always rebuild home (it aggregates counts/lists from other passes)
+ local home_md="${TMPDIR_BUILD}/index.home.md"
+ cp "${src}" "${home_md}"
+
+ # Inject garden count
+ sed -i "s/\[%garden\]/${garden_count}/g" "${home_md}"
+
+ # Recent blog entries
+ {
+ printf '\n\n## recent entries:\n\n'
+ local blog_list="${TMPDIR_BUILD}/blog.list.md"
+ if [[ -f "${blog_list}" && -s "${blog_list}" ]]; then
+ sort -r "${blog_list}" | head -n "${LIST_COUNT}"
+ local total
+ total="$(wc -l < "${blog_list}")"
+ [[ "${total}" -gt "${LIST_COUNT}" ]] && printf -- '- [all ..](/blog)\n'
+ else
+ printf -- '- stuff I wrote should be listed here\n'
+ fi
+ } >> "${home_md}"
+
+ # Recent talks
+ {
+ printf '\n\n## talks & workshops:\n\n'
+ local talks_list="${TMPDIR_BUILD}/talks.list.md"
+ if [[ -f "${talks_list}" && -s "${talks_list}" ]]; then
+ sort -r "${talks_list}" | head -n "${LIST_COUNT}"
+ local total
+ total="$(wc -l < "${talks_list}")"
+ [[ "${total}" -gt "${LIST_COUNT}" ]] && printf -- '- [all ..](/talks)\n'
+ else
+ printf -- '- stuff I said should be listed here\n'
+ fi
+ } >> "${home_md}"
+
+ # Projects
+ {
+ printf '\n\n## projects:\n\n'
+ local proj_list="${TMPDIR_BUILD}/projects.list.md"
+ if [[ -f "${proj_list}" && -s "${proj_list}" ]]; then
+ head -n "${LIST_COUNT}" "${proj_list}"
+ local total
+ total="$(wc -l < "${proj_list}")"
+ [[ "${total}" -gt "${LIST_COUNT}" ]] && printf -- '- [all ..](/projects)\n'
+ else
+ printf -- '- stuff I made should be listed here\n'
+ fi
+ } >> "${home_md}"
+
+ # Code snippets
+ {
+ local code_list="${TMPDIR_BUILD}/code.list.md"
+ if [[ -f "${code_list}" && -s "${code_list}" ]]; then
+ printf '\n\n## code snippets:\n\n'
+ head -n "${LIST_COUNT}" "${code_list}"
+ local total
+ total="$(wc -l < "${code_list}")"
+ [[ "${total}" -gt "${LIST_COUNT}" ]] && printf -- '- [all ..](/code)\n'
+ fi
+ } >> "${home_md}"
+
+ # Recipes
+ {
+ local recipes_list="${TMPDIR_BUILD}/recipes.list.md"
+ if [[ -f "${recipes_list}" && -s "${recipes_list}" ]]; then
+ printf '\n\n## recipes:\n\n'
+ head -n "${LIST_COUNT}" "${recipes_list}"
+ local total
+ total="$(wc -l < "${recipes_list}")"
+ [[ "${total}" -gt "${LIST_COUNT}" ]] && printf -- '- [all ..](/recipes)\n'
+ fi
+ } >> "${home_md}"
+
+ # Slides
+ {
+ local slides_list="${TMPDIR_BUILD}/slides.list.md"
+ if [[ -f "${slides_list}" && -s "${slides_list}" ]]; then
+ printf '\n\n## slides:\n\n'
+ sort -r "${slides_list}" | head -n "${LIST_COUNT}"
+ local total
+ total="$(wc -l < "${slides_list}")"
+ [[ "${total}" -gt "${LIST_COUNT}" ]] && printf -- '- [all ..](/slides)\n'
+ fi
+ } >> "${home_md}"
+
+ local content
+ content="$(lowdown "${home_md}")"
+ check_has_code "${content}"
+
+ export PAGE_TITLE=""
+ export CONTENT="${content}"
+ export BREADCRUMBS=""
+ export HEADER_EXTRA=""
+ export META_DATE=""
+ export META_DESCRIPTION=""
+ export HAS_CODE
+ export LANG="en"
+ export DIR=""
+ export IS_HOME="true"
+ export ARCHIVE_TITLE=""
+
+ render_page "${tmpl}" "${out_html}"
+ echo "[built] ${out_html}"
+}
+
+# ---------------------------------------------------------------------------
+# Main build
+# ---------------------------------------------------------------------------
+
+mkdir -p output
+
+# Copy static assets
+cp -ar static/* output/ 2>/dev/null || true
+cp -ar includes output/ 2>/dev/null || true
+cp -ar images output/ 2>/dev/null || true
+echo "[static] assets copied"
+
+# Content sections
+process_content "blog" "blog" "blog archive" "true"
+process_content "talks" "talk" "talks & workshops" "true"
+process_content "code" "code" "code snippets" "false"
+process_content "recipes" "recipe" "recipes" "false"
+
+# (list files already live in TMPDIR_BUILD with canonical names)
+
+process_projects
+process_slides
+
+# Garden (count pages for home page injection)
+garden_count=0
+process_garden garden
+
+# Includes + license
+build_includes
+build_license
+
+# Home (uses all collected list files)
+build_home
+
+# RSS + Sitemap
+generate_rss_feed
+generate_sitemap
+
+echo ""
+echo "[done] Build complete. Output in output/"
+
+# ---------------------------------------------------------------------------
+# --serve
+# ---------------------------------------------------------------------------
+if [[ "${DO_SERVE}" == true ]]; then
+ if command -v darkhttpd &>/dev/null; then
+ echo "[serve] Starting darkhttpd on http://localhost:8080"
+ darkhttpd output --port 8080
+ else
+ echo "[error] darkhttpd not found. Install it to use --serve." >&2
+ exit 1
+ fi
+fi