#!/bin/bash
+#
+# =============================================================================
+# Update web site
+# =============================================================================
+#
+# PURPOSE
+# Convert every Emacs org-mode file in this repository to HTML, then upload
+# the result to the public web server.
+#
+# WHY A SCRIPT?
+# HTML files are NOT versioned in git — they are regenerated on demand from
+# the .org source files. This script runs the Emacs export in batch mode for
+# every page, then rsyncs the generated HTML to the server.
+#
+# HOW THE INCREMENTAL CACHE WORKS
+# The first time this script runs, it exports every .org file (except
+# AGENTS.org) and writes a small state file called `.org-export-state` at
+# the repo root. The state file records:
+#
+# - one sha256 hash per exported .org file
+# - one sha256 hash of style.css (the "shared sentinel")
+#
+# On each subsequent run, before exporting a .org file, the script checks
+# its current hash against the stored hash. If they match, the file is
+# skipped — Emacs is not invoked at all. This is what makes re-runs fast
+# when you've only changed one or two pages.
+#
+# The style.css sentinel is checked on every run. If style.css has changed
+# since the last publish, every .org file is re-exported automatically.
+# This catches style changes that you would otherwise have to remember to
+# force.
+#
+# AGENTS.org is excluded from exports — it is the operating guide for
+# agents (this file you're reading is generated from a different script).
+#
+# USAGE
+# ./Tools/Update web site # re-export only changed files, then upload
+# ./Tools/Update web site --force # re-export everything, then upload
+# ./Tools/Update web site --rebuild # same as --force
+#
+# The first time you run the script, it will re-open itself in a
+# gnome-terminal window so the output is scrollable. The "T" arg is the
+# internal signal that says "we're already inside the terminal, don't
+# re-open."
+#
+# =============================================================================
-# I use this script to update project website.
-# HTML files are NOT versioned. They are generated on-demand from .org files.
-
+# --- gnome-terminal re-launch trick -------------------------------------------
+# This line says: if we are NOT being run with the internal "T" flag, re-launch
+# ourselves inside a gnome-terminal window and exit the original process. This
+# gives us a scrollable terminal without requiring the user to manually open
+# one. The "${0%/*}" part strips the script name from $0 to get the script's
+# own directory, so `cd` runs us into the right place regardless of where we
+# were invoked from.
cd "${0%/*}"; if [ "$1" != "T" ]; then gnome-terminal -- "$0" T; exit; fi;
+# Now we're inside the terminal. Change into the repo root (one level up from
+# the Tools/ directory where this script lives).
cd ..
-# Function to export org to html using emacs in batch mode
+# --- Configuration ------------------------------------------------------------
+
+# Path to the cache file (relative to the repo root).
+STATE_FILE=".org-export-state"
+
+# --force / --rebuild: ignore the cache and re-export everything.
+FORCE=0
+for arg in "$@"; do
+ case "$arg" in
+ --force|--rebuild) FORCE=1 ;;
+ esac
+done
+
+# --- Helper functions ---------------------------------------------------------
+# These small functions are defined first so the main export logic below
+# reads top-to-bottom. Each one does exactly one thing.
+
+# file_sha <path>
+# Print the sha256 hash of a file, or an empty string if the file does not
+# exist. sha256sum always prints "<hash> <filename>", so we use awk to keep
+# only the first column.
+file_sha() {
+ [ -f "$1" ] && sha256sum "$1" | awk '{print $1}'
+}
+
+# write_state_file
+# Save the current STATE_HASHES associative array and STYLE_SENTINEL
+# variable to disk. We write to a temp file first, then `mv` it into place —
+# this is the standard "atomic write" pattern. If the script were killed
+# mid-write, you'd otherwise end up with a half-written state file; with
+# mv, you either get the old file or the new file, never a broken one.
+#
+# The "$$" in the temp filename is bash's process ID — it makes the temp
+# filename unique even if two copies of the script run at the same time.
+write_state_file() {
+ local tmp="${STATE_FILE}.tmp.$$"
+ {
+ echo "# svjatoslav-physical incremental-export state. Do not edit by hand."
+ echo "# Lines starting with '#' are comments. Empty lines ignored."
+ echo "# Format: <sha256> <relative-path>"
+ # "${!ARRAY[@]}" expands to the list of KEYS in an associative array.
+ for path in "${!STATE_HASHES[@]}"; do
+ echo "${STATE_HASHES[$path]} $path"
+ done
+ echo "sentinel-style-css: ${STYLE_SENTINEL} style.css"
+ } > "$tmp"
+ mv "$tmp" "$STATE_FILE"
+}
+
+# load_state_file
+# Read the state file from disk and populate STATE_HASHES and STYLE_SENTINEL.
+# Returns 0 (success) if the file was loaded, 1 if it was missing.
+#
+# The `while IFS= read -r line` loop is the standard bash idiom for reading
+# a file line-by-line:
+# - IFS= prevents leading/trailing whitespace from being stripped
+# - -r prevents backslashes from being interpreted as escapes
+# - < "$STATE_FILE" redirects the file into the loop's stdin
+#
+# The state file uses two line formats:
+# "<sha256> <path>" — one entry per .org file
+# "sentinel-style-css: <sha256> style.css" — the style sentinel
+# We distinguish them with a case-statement on the prefix.
+load_state_file() {
+ [ -f "$STATE_FILE" ] || return 1
+ # `declare -gA` declares a global associative array (-A = key/value, -g =
+ # visible to the caller, not just this function). We have to redeclare
+ # inside this function because bash's scoping rules don't automatically
+ # see the caller's declarations.
+ declare -gA STATE_HASHES
+ STATE_HASHES=()
+ STYLE_SENTINEL=""
+ while IFS= read -r line; do
+ case "$line" in
+ ""|\#*) continue ;; # skip blank lines and comments
+ sentinel-style-css:*)
+ # Strip the "sentinel-style-css:" prefix, then trim spaces.
+ # ${var#prefix} = remove prefix, ${var%suffix} = remove suffix.
+ STYLE_SENTINEL="${line#sentinel-style-css:}"
+ STYLE_SENTINEL="${STYLE_SENTINEL%% *}"
+ STYLE_SENTINEL="${STYLE_SENTINEL##* }"
+ ;;
+ *)
+ # Format: "<sha256> <path>" — two spaces separate the fields.
+ local hash="${line%% *}" # everything before the first " "
+ local path="${line#* }" # everything after the first " "
+ STATE_HASHES["$path"]="$hash"
+ ;;
+ esac
+ done < "$STATE_FILE"
+ return 0
+}
+
+# update_state_hash <path> <hash>
+# Update the in-memory cache for one .org file. The cache is flushed to
+# disk by write_state_file at the end of the run, not on every update —
+# this avoids disk I/O in the hot loop.
+update_state_hash() {
+ local path="$1"
+ local hash="$2"
+ STATE_HASHES["$path"]="$hash"
+}
+
+# export_org_to_html <relative-path>
+# Convert one .org file to .html using Emacs in batch mode.
+#
+# We run the conversion inside a subshell — the "( ... )" parens below —
+# so that the `cd "$dir"` only affects the export, not the main script.
+# Without the subshell, the script's own working directory would get stuck
+# in the last directory we exported.
+#
+# --batch : non-interactive mode
+# -l ~/.emacs : load your init file (defines darksun theme etc.)
+# --visit=<file> : open the .org file
+# --funcall=... : call the org-html-export function
+# --kill : exit Emacs when done
export_org_to_html() {
local org_file=$1
local dir=$(dirname "$org_file")
(
cd "$dir" || return 1
local html_file="${base}.html"
+ # Delete the old HTML so we don't accidentally read a stale one
+ # if the export fails partway through.
if [ -f "$html_file" ]; then
rm -f "$html_file"
fi
emacs --batch -l ~/.emacs --visit="${base}.org" --funcall=org-html-export-to-html --kill
if [ $? -eq 0 ]; then
echo "✓ Successfully exported $org_file"
+ return 0
else
echo "✗ Failed to export $org_file"
return 1
)
}
+# --- Main export routine ------------------------------------------------------
+
+# export_org_files_to_html
+# The main work: find all .org files, decide which ones to skip, and call
+# export_org_to_html on the rest. At the end, writes the updated state file.
export_org_files_to_html() {
echo "🔍 Searching for .org files ..."
echo "======================================="
- mapfile -t ORG_FILES < <(find . -type f -name "*.org" | sort)
+ # mapfile -t ARRAY < <(command) reads the lines of `command`'s output
+ # into ARRAY. The -t strips trailing newlines. We exclude AGENTS.org
+ # because it is for agents (this file's sibling), not for the public site.
+ mapfile -t ORG_FILES < <(find . -type f -name "*.org" ! -name "AGENTS.org" | sort)
if [ ${#ORG_FILES[@]} -eq 0 ]; then
echo "❌ No .org files found!"
return 1
fi
- echo "Found ${#ORG_FILES[@]} .org file(s):"
+ echo "Found ${#ORG_FILES[@]} .org file(s) (excluded: AGENTS.org):"
printf '%s\n' "${ORG_FILES[@]}"
echo "======================================="
+ # --- Load the cache (unless --force was given) ---------------------------
+ # `declare -A` makes STATE_HASHES a local associative array. If the state
+ # file doesn't exist, this is the first run and we'll export everything.
+ declare -A STATE_HASHES=()
+ STYLE_SENTINEL=""
+ if [ "$FORCE" -eq 0 ]; then
+ if load_state_file; then
+ echo "📋 Loaded state from $STATE_FILE (${#STATE_HASHES[@]} files, sentinel ${STYLE_SENTINEL:-<unset>})"
+ else
+ echo "📋 No $STATE_FILE found — first run, will create one"
+ fi
+ else
+ echo "📋 --force specified, ignoring $STATE_FILE"
+ fi
+
+ # --- Sentinel check ------------------------------------------------------
+ # If style.css has been modified since the last run, invalidate the cache
+ # for every page. This is the "safety net" for the common case of
+ # editing CSS: you don't have to remember --force.
+ local current_style_hash
+ current_style_hash=$(file_sha "style.css")
+ local style_changed=0
+ if [ "$FORCE" -eq 0 ] && [ -n "$STYLE_SENTINEL" ] && [ "$STYLE_SENTINEL" != "$current_style_hash" ]; then
+ echo "🎨 style.css sentinel changed (${STYLE_SENTINEL:0:12}… → ${current_style_hash:0:12}…) — invalidating all pages"
+ style_changed=1
+ fi
+ if [ -z "$STYLE_SENTINEL" ]; then
+ STYLE_SENTINEL="$current_style_hash"
+ fi
+
+ # --- The main loop -------------------------------------------------------
SUCCESS_COUNT=0
FAILED_COUNT=0
+ SKIPPED_COUNT=0
for org_file in "${ORG_FILES[@]}"; do
+ local current_hash
+ current_hash=$(file_sha "$org_file")
+
+ # "${ARR[key]:-}" returns the value or an empty string if unset.
+ # We use it to fetch the cached hash for this file.
+ local cached_hash="${STATE_HASHES[$org_file]:-}"
+
+ # Skip the export if:
+ # - --force is NOT set, AND
+ # - the sentinel hasn't invalidated the cache, AND
+ # - we have a cached hash for this file, AND
+ # - the cached hash matches the current hash
+ if [ "$FORCE" -eq 0 ] && [ "$style_changed" -eq 0 ] && [ -n "$cached_hash" ] && [ "$cached_hash" = "$current_hash" ]; then
+ echo "↻ Skipping (unchanged): $org_file"
+ # ((VAR++)) is arithmetic increment — only works because VAR=0.
+ ((SKIPPED_COUNT++))
+ continue
+ fi
+
export_org_to_html "$org_file"
if [ $? -eq 0 ]; then
+ # Update the in-memory cache; written to disk after the loop.
+ update_state_hash "$org_file" "$current_hash"
((SUCCESS_COUNT++))
else
((FAILED_COUNT++))
fi
done
+ # --- Persist the updated cache ------------------------------------------
+ write_state_file
+ echo "💾 Wrote state to $STATE_FILE"
+
echo "======================================="
echo "📊 SUMMARY:"
- echo " ✓ Successful: $SUCCESS_COUNT"
- echo " ✗ Failed: $FAILED_COUNT"
- echo " Total: $((SUCCESS_COUNT + FAILED_COUNT))"
+ echo " ✓ Exported: $SUCCESS_COUNT"
+ echo " ↻ Skipped: $SKIPPED_COUNT"
+ echo " ✗ Failed: $FAILED_COUNT"
+ echo " Total: $((SUCCESS_COUNT + SKIPPED_COUNT + FAILED_COUNT))"
echo ""
}
-# Publish Emacs org-mode files into HTML format
+# --- Run it -------------------------------------------------------------------
+
+# Convert all .org files to .html (with the cache logic above).
export_org_files_to_html
-## Upload assembled documentation to server
-# Exclude tools/ and .git/, exclude all .org files (source), keep generated .html and assets
+# --- Upload to the web server ------------------------------------------------
+# rsync flags used here:
+# -a : archive mode (preserves permissions, timestamps, etc.)
+# -v : verbose (list every file)
+# -z : compress during transfer
+# --delete : delete files on the server that no longer exist locally
+# -e 'ssh -p 10006' : use ssh on port 10006 for the connection
+#
+# We exclude:
+# tools/ — this directory contains the script itself, not published content
+# .git/ — version control metadata
+# *.org — source files, only the generated HTML goes public
rsync -avz --delete -e 'ssh -p 10006' \
--exclude="tools/" \
--exclude=".git/" \
echo "✗ Upload failed!"
fi
+# Keep the terminal window open so the user can read the output. The `read`
+# without a variable just waits for any keypress before exiting.
echo ""
echo "Press ENTER to close this window."
read