summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-06-16 18:00:47 +0300
committerPaul Buetow <paul@buetow.org>2026-06-16 18:00:47 +0300
commitcd7cf797bc633700c5ee7e986077f366c000f0b9 (patch)
treedb7bda7af7a71359cba6bc6c0557552e8659fe8c
parentb58d9b3f656f2e6be803a1d2d567a066bb8b902a (diff)
hn0 make template render kind dispatch extensible (OCP)
Replace the hardcoded `case "$kind"` block in prepare_template_render_vars with a name-based registration/dispatch pattern. Each render field kind is now implemented by one prepare_template_render_var__<kind> handler; the core loop resolves the handler by name (prepare_template_render_var__$kind), verifies it exists via `declare -F`, calls it with a uniform signature (out_nameref, context_name, source_name), and reports a config_error for an unknown kind (no matching handler) -- preserving the previous error behavior. Adding a new kind now means defining a new handler function only; the loop never changes. Handlers cover all existing kinds: context_css, context_html, context_raw, current_date_html, config_html (keeps its inner source_name dispatch for HEIGHT/MAXPREVIEWS/TITLE/etc. and the same :- defaults), original_basepath_is_set, preview_num_next_html, preview_num_prev_html, and tarball_include. Escaping and defaults are unchanged, so rendered output is byte-identical (verified by diff -r of a full --generate album, before vs after). Add test_template_render_var_dispatch_is_extensible proving every declared kind resolves to a handler and that a newly defined handler is dispatched without touching the core loop (OCP). just test, just shellcheck, just check-generated and git diff --check all pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
-rwxr-xr-xbin/shuriken265
-rw-r--r--src/lib/template.source.sh265
-rwxr-xr-xtests/cli.sh61
3 files changed, 415 insertions, 176 deletions
diff --git a/bin/shuriken b/bin/shuriken
index 712a4a1..f8aeaf2 100755
--- a/bin/shuriken
+++ b/bin/shuriken
@@ -924,19 +924,183 @@ serialize_template_render_context() {
done
}
+# --- Per-kind render handlers (Open/Closed dispatch) --------------------------
+#
+# Each TEMPLATE_RENDER_FIELD_SPECS "kind" is implemented by one
+# prepare_template_render_var__<kind> function. prepare_template_render_vars
+# resolves the handler by name (prepare_template_render_var__$kind) and calls
+# it, so a NEW kind is added simply by defining a new handler function with the
+# matching name -- the core loop never needs editing (Open/Closed Principle).
+#
+# Every handler shares the same calling convention so the loop can invoke them
+# uniformly:
+# prepare_template_render_var__<kind> <render_value_out> \
+# <context_name> <source_name>
+# The first argument is an output nameref the handler writes the final
+# render_value into; handlers that do not need the context name or source_name
+# simply ignore those arguments. Handlers reproduce exactly what the old
+# "case $kind" arms produced (same escaping and defaults), so rendered output
+# is byte-identical.
+#
+# Note: SC2317 cannot see these handlers being called -- the dispatch is by
+# computed name -- so they would look unreachable/unused. The disable directive
+# on each handler documents that it is reached dynamically via declare -F lookup.
+
+# shellcheck disable=SC2317 # called dynamically by prepare_template_render_vars
+prepare_template_render_var__context_css() {
+ local -n out_ref="$1"; shift
+ local -r context_name="$1"; shift
+ local -r source_name="$1"; shift
+ local context_value
+
+ template_context_value_to context_value "$context_name" "$source_name"
+ css_string_escape_to out_ref "$context_value"
+}
+
+# shellcheck disable=SC2317 # called dynamically by prepare_template_render_vars
+prepare_template_render_var__context_html() {
+ local -n out_ref="$1"; shift
+ local -r context_name="$1"; shift
+ local -r source_name="$1"; shift
+ local context_value
+
+ template_context_value_to context_value "$context_name" "$source_name"
+ html_escape_to out_ref "$context_value"
+}
+
+# shellcheck disable=SC2317 # called dynamically by prepare_template_render_vars
+prepare_template_render_var__context_raw() {
+ local -n out_ref="$1"; shift
+ local -r context_name="$1"; shift
+ local -r source_name="$1"; shift
+
+ template_context_value_to out_ref "$context_name" "$source_name"
+}
+
+# shellcheck disable=SC2317 # called dynamically by prepare_template_render_vars
+prepare_template_render_var__current_date_html() {
+ local -n out_ref="$1"; shift
+ local context_value
+
+ current_date_text_to context_value
+ html_escape_to out_ref "$context_value"
+}
+
+# config_html resolves its value from a named config variable. The inner
+# source_name dispatch (HEIGHT/MAXPREVIEWS/TITLE/etc.) stays here so config
+# handling is self-contained; its behavior is identical to the old arm.
+# shellcheck disable=SC2317 # called dynamically by prepare_template_render_vars
+prepare_template_render_var__config_html() {
+ local -n out_ref="$1"; shift
+ local -r _context_name="$1"; shift
+ local -r source_name="$1"; shift
+ local context_value
+
+ case "$source_name" in
+ HEIGHT)
+ context_value="$HEIGHT"
+ ;;
+ MAXPREVIEWS)
+ # Refresh-only configs do not require generation
+ # sizing fields; render absent values as empty.
+ context_value="${MAXPREVIEWS:-}"
+ ;;
+ ORIGINAL_BASEPATH)
+ context_value="$ORIGINAL_BASEPATH"
+ ;;
+ STATS_PAGE)
+ # Always defaulted by apply_config_defaults; degrade to
+ # "no" (link hidden) if somehow unset so the header bar
+ # never references a stats page that was not generated.
+ context_value="${STATS_PAGE:-no}"
+ ;;
+ THUMBHEIGHT)
+ # Refresh-only configs do not require generation
+ # sizing fields; render absent values as empty.
+ context_value="${THUMBHEIGHT:-}"
+ ;;
+ TITLE)
+ context_value="$TITLE"
+ ;;
+ *)
+ config_error "unknown template render config $source_name"
+ return 1
+ ;;
+ esac
+ html_escape_to out_ref "$context_value"
+}
+
+# shellcheck disable=SC2317 # called dynamically by prepare_template_render_vars
+prepare_template_render_var__original_basepath_is_set() {
+ # shellcheck disable=SC2034 # written through the output nameref
+ local -n out_ref="$1"; shift
+
+ if [ -n "$ORIGINAL_BASEPATH" ]; then
+ out_ref='yes'
+ else
+ out_ref='no'
+ fi
+}
+
+# shellcheck disable=SC2317 # called dynamically by prepare_template_render_vars
+prepare_template_render_var__preview_num_next_html() {
+ # shellcheck disable=SC2034 # written through the output nameref
+ local -n out_ref="$1"; shift
+ local -r context_name="$1"; shift
+ local -r source_name="$1"; shift
+ local context_value
+
+ template_context_value_to context_value "$context_name" "$source_name"
+ if [ -n "$context_value" ]; then
+ html_escape_to out_ref "$(( context_value + 1 ))"
+ else
+ out_ref=''
+ fi
+}
+
+# shellcheck disable=SC2317 # called dynamically by prepare_template_render_vars
+prepare_template_render_var__preview_num_prev_html() {
+ # shellcheck disable=SC2034 # written through the output nameref
+ local -n out_ref="$1"; shift
+ local -r context_name="$1"; shift
+ local -r source_name="$1"; shift
+ local context_value
+
+ template_context_value_to context_value "$context_name" "$source_name"
+ if [ -n "$context_value" ]; then
+ html_escape_to out_ref "$(( context_value - 1 ))"
+ else
+ out_ref=''
+ fi
+}
+
+# shellcheck disable=SC2317 # called dynamically by prepare_template_render_vars
+prepare_template_render_var__tarball_include() {
+ local -n out_ref="$1"; shift
+
+ # shellcheck disable=SC2034 # written through the output nameref
+ out_ref="$TARBALL_INCLUDE"
+}
+
+# Core render loop. For each field spec it resolves the per-kind handler by name
+# (prepare_template_render_var__<kind>) and dispatches to it; adding a kind means
+# defining a new handler function, never touching this loop. An unknown kind --
+# one with no matching handler function -- is a config error, preserving the old
+# "unknown template render field kind" behavior.
prepare_template_render_vars() {
local -r render_vars_name="$1"; shift
local -r context_name="$1"; shift
# shellcheck disable=SC2178
local -n render_vars_ref="$render_vars_name"
- local context_value
local field_spec
+ local handler
local kind
local render_value
local render_var
local _required_context_var
local _required_templates
local source_name
+ local -i status=0
render_vars_ref=()
@@ -944,93 +1108,18 @@ prepare_template_render_vars() {
IFS='|' read -r render_var kind source_name \
_required_context_var _required_templates <<< "$field_spec"
- case "$kind" in
- context_css)
- template_context_value_to \
- context_value "$context_name" "$source_name"
- css_string_escape_to render_value "$context_value"
- ;;
- context_html)
- template_context_value_to \
- context_value "$context_name" "$source_name"
- html_escape_to render_value "$context_value"
- ;;
- context_raw)
- template_context_value_to \
- render_value "$context_name" "$source_name"
- ;;
- current_date_html)
- current_date_text_to context_value
- html_escape_to render_value "$context_value"
- ;;
- config_html)
- case "$source_name" in
- HEIGHT)
- context_value="$HEIGHT"
- ;;
- MAXPREVIEWS)
- # Refresh-only configs do not require generation
- # sizing fields; render absent values as empty.
- context_value="${MAXPREVIEWS:-}"
- ;;
- ORIGINAL_BASEPATH)
- context_value="$ORIGINAL_BASEPATH"
- ;;
- STATS_PAGE)
- # Always defaulted by apply_config_defaults; degrade to
- # "no" (link hidden) if somehow unset so the header bar
- # never references a stats page that was not generated.
- context_value="${STATS_PAGE:-no}"
- ;;
- THUMBHEIGHT)
- # Refresh-only configs do not require generation
- # sizing fields; render absent values as empty.
- context_value="${THUMBHEIGHT:-}"
- ;;
- TITLE)
- context_value="$TITLE"
- ;;
- *)
- config_error \
- "unknown template render config $source_name"
- return 1
- ;;
- esac
- html_escape_to render_value "$context_value"
- ;;
- original_basepath_is_set)
- if [ -n "$ORIGINAL_BASEPATH" ]; then
- render_value='yes'
- else
- render_value='no'
- fi
- ;;
- preview_num_next_html)
- template_context_value_to \
- context_value "$context_name" "$source_name"
- if [ -n "$context_value" ]; then
- html_escape_to render_value "$(( context_value + 1 ))"
- else
- render_value=''
- fi
- ;;
- preview_num_prev_html)
- template_context_value_to \
- context_value "$context_name" "$source_name"
- if [ -n "$context_value" ]; then
- html_escape_to render_value "$(( context_value - 1 ))"
- else
- render_value=''
- fi
- ;;
- tarball_include)
- render_value="$TARBALL_INCLUDE"
- ;;
- *)
- config_error "unknown template render field kind $kind"
- return 1
- ;;
- esac
+ handler="prepare_template_render_var__$kind"
+ if ! declare -F "$handler" > /dev/null; then
+ config_error "unknown template render field kind $kind"
+ return 1
+ fi
+
+ render_value=''
+ "$handler" render_value "$context_name" "$source_name"
+ status=$?
+ if (( status != 0 )); then
+ return "$status"
+ fi
render_vars_ref["$render_var"]="$render_value"
done
diff --git a/src/lib/template.source.sh b/src/lib/template.source.sh
index dd62c42..13a2988 100644
--- a/src/lib/template.source.sh
+++ b/src/lib/template.source.sh
@@ -454,19 +454,183 @@ serialize_template_render_context() {
done
}
+# --- Per-kind render handlers (Open/Closed dispatch) --------------------------
+#
+# Each TEMPLATE_RENDER_FIELD_SPECS "kind" is implemented by one
+# prepare_template_render_var__<kind> function. prepare_template_render_vars
+# resolves the handler by name (prepare_template_render_var__$kind) and calls
+# it, so a NEW kind is added simply by defining a new handler function with the
+# matching name -- the core loop never needs editing (Open/Closed Principle).
+#
+# Every handler shares the same calling convention so the loop can invoke them
+# uniformly:
+# prepare_template_render_var__<kind> <render_value_out> \
+# <context_name> <source_name>
+# The first argument is an output nameref the handler writes the final
+# render_value into; handlers that do not need the context name or source_name
+# simply ignore those arguments. Handlers reproduce exactly what the old
+# "case $kind" arms produced (same escaping and defaults), so rendered output
+# is byte-identical.
+#
+# Note: SC2317 cannot see these handlers being called -- the dispatch is by
+# computed name -- so they would look unreachable/unused. The disable directive
+# on each handler documents that it is reached dynamically via declare -F lookup.
+
+# shellcheck disable=SC2317 # called dynamically by prepare_template_render_vars
+prepare_template_render_var__context_css() {
+ local -n out_ref="$1"; shift
+ local -r context_name="$1"; shift
+ local -r source_name="$1"; shift
+ local context_value
+
+ template_context_value_to context_value "$context_name" "$source_name"
+ css_string_escape_to out_ref "$context_value"
+}
+
+# shellcheck disable=SC2317 # called dynamically by prepare_template_render_vars
+prepare_template_render_var__context_html() {
+ local -n out_ref="$1"; shift
+ local -r context_name="$1"; shift
+ local -r source_name="$1"; shift
+ local context_value
+
+ template_context_value_to context_value "$context_name" "$source_name"
+ html_escape_to out_ref "$context_value"
+}
+
+# shellcheck disable=SC2317 # called dynamically by prepare_template_render_vars
+prepare_template_render_var__context_raw() {
+ local -n out_ref="$1"; shift
+ local -r context_name="$1"; shift
+ local -r source_name="$1"; shift
+
+ template_context_value_to out_ref "$context_name" "$source_name"
+}
+
+# shellcheck disable=SC2317 # called dynamically by prepare_template_render_vars
+prepare_template_render_var__current_date_html() {
+ local -n out_ref="$1"; shift
+ local context_value
+
+ current_date_text_to context_value
+ html_escape_to out_ref "$context_value"
+}
+
+# config_html resolves its value from a named config variable. The inner
+# source_name dispatch (HEIGHT/MAXPREVIEWS/TITLE/etc.) stays here so config
+# handling is self-contained; its behavior is identical to the old arm.
+# shellcheck disable=SC2317 # called dynamically by prepare_template_render_vars
+prepare_template_render_var__config_html() {
+ local -n out_ref="$1"; shift
+ local -r _context_name="$1"; shift
+ local -r source_name="$1"; shift
+ local context_value
+
+ case "$source_name" in
+ HEIGHT)
+ context_value="$HEIGHT"
+ ;;
+ MAXPREVIEWS)
+ # Refresh-only configs do not require generation
+ # sizing fields; render absent values as empty.
+ context_value="${MAXPREVIEWS:-}"
+ ;;
+ ORIGINAL_BASEPATH)
+ context_value="$ORIGINAL_BASEPATH"
+ ;;
+ STATS_PAGE)
+ # Always defaulted by apply_config_defaults; degrade to
+ # "no" (link hidden) if somehow unset so the header bar
+ # never references a stats page that was not generated.
+ context_value="${STATS_PAGE:-no}"
+ ;;
+ THUMBHEIGHT)
+ # Refresh-only configs do not require generation
+ # sizing fields; render absent values as empty.
+ context_value="${THUMBHEIGHT:-}"
+ ;;
+ TITLE)
+ context_value="$TITLE"
+ ;;
+ *)
+ config_error "unknown template render config $source_name"
+ return 1
+ ;;
+ esac
+ html_escape_to out_ref "$context_value"
+}
+
+# shellcheck disable=SC2317 # called dynamically by prepare_template_render_vars
+prepare_template_render_var__original_basepath_is_set() {
+ # shellcheck disable=SC2034 # written through the output nameref
+ local -n out_ref="$1"; shift
+
+ if [ -n "$ORIGINAL_BASEPATH" ]; then
+ out_ref='yes'
+ else
+ out_ref='no'
+ fi
+}
+
+# shellcheck disable=SC2317 # called dynamically by prepare_template_render_vars
+prepare_template_render_var__preview_num_next_html() {
+ # shellcheck disable=SC2034 # written through the output nameref
+ local -n out_ref="$1"; shift
+ local -r context_name="$1"; shift
+ local -r source_name="$1"; shift
+ local context_value
+
+ template_context_value_to context_value "$context_name" "$source_name"
+ if [ -n "$context_value" ]; then
+ html_escape_to out_ref "$(( context_value + 1 ))"
+ else
+ out_ref=''
+ fi
+}
+
+# shellcheck disable=SC2317 # called dynamically by prepare_template_render_vars
+prepare_template_render_var__preview_num_prev_html() {
+ # shellcheck disable=SC2034 # written through the output nameref
+ local -n out_ref="$1"; shift
+ local -r context_name="$1"; shift
+ local -r source_name="$1"; shift
+ local context_value
+
+ template_context_value_to context_value "$context_name" "$source_name"
+ if [ -n "$context_value" ]; then
+ html_escape_to out_ref "$(( context_value - 1 ))"
+ else
+ out_ref=''
+ fi
+}
+
+# shellcheck disable=SC2317 # called dynamically by prepare_template_render_vars
+prepare_template_render_var__tarball_include() {
+ local -n out_ref="$1"; shift
+
+ # shellcheck disable=SC2034 # written through the output nameref
+ out_ref="$TARBALL_INCLUDE"
+}
+
+# Core render loop. For each field spec it resolves the per-kind handler by name
+# (prepare_template_render_var__<kind>) and dispatches to it; adding a kind means
+# defining a new handler function, never touching this loop. An unknown kind --
+# one with no matching handler function -- is a config error, preserving the old
+# "unknown template render field kind" behavior.
prepare_template_render_vars() {
local -r render_vars_name="$1"; shift
local -r context_name="$1"; shift
# shellcheck disable=SC2178
local -n render_vars_ref="$render_vars_name"
- local context_value
local field_spec
+ local handler
local kind
local render_value
local render_var
local _required_context_var
local _required_templates
local source_name
+ local -i status=0
render_vars_ref=()
@@ -474,93 +638,18 @@ prepare_template_render_vars() {
IFS='|' read -r render_var kind source_name \
_required_context_var _required_templates <<< "$field_spec"
- case "$kind" in
- context_css)
- template_context_value_to \
- context_value "$context_name" "$source_name"
- css_string_escape_to render_value "$context_value"
- ;;
- context_html)
- template_context_value_to \
- context_value "$context_name" "$source_name"
- html_escape_to render_value "$context_value"
- ;;
- context_raw)
- template_context_value_to \
- render_value "$context_name" "$source_name"
- ;;
- current_date_html)
- current_date_text_to context_value
- html_escape_to render_value "$context_value"
- ;;
- config_html)
- case "$source_name" in
- HEIGHT)
- context_value="$HEIGHT"
- ;;
- MAXPREVIEWS)
- # Refresh-only configs do not require generation
- # sizing fields; render absent values as empty.
- context_value="${MAXPREVIEWS:-}"
- ;;
- ORIGINAL_BASEPATH)
- context_value="$ORIGINAL_BASEPATH"
- ;;
- STATS_PAGE)
- # Always defaulted by apply_config_defaults; degrade to
- # "no" (link hidden) if somehow unset so the header bar
- # never references a stats page that was not generated.
- context_value="${STATS_PAGE:-no}"
- ;;
- THUMBHEIGHT)
- # Refresh-only configs do not require generation
- # sizing fields; render absent values as empty.
- context_value="${THUMBHEIGHT:-}"
- ;;
- TITLE)
- context_value="$TITLE"
- ;;
- *)
- config_error \
- "unknown template render config $source_name"
- return 1
- ;;
- esac
- html_escape_to render_value "$context_value"
- ;;
- original_basepath_is_set)
- if [ -n "$ORIGINAL_BASEPATH" ]; then
- render_value='yes'
- else
- render_value='no'
- fi
- ;;
- preview_num_next_html)
- template_context_value_to \
- context_value "$context_name" "$source_name"
- if [ -n "$context_value" ]; then
- html_escape_to render_value "$(( context_value + 1 ))"
- else
- render_value=''
- fi
- ;;
- preview_num_prev_html)
- template_context_value_to \
- context_value "$context_name" "$source_name"
- if [ -n "$context_value" ]; then
- html_escape_to render_value "$(( context_value - 1 ))"
- else
- render_value=''
- fi
- ;;
- tarball_include)
- render_value="$TARBALL_INCLUDE"
- ;;
- *)
- config_error "unknown template render field kind $kind"
- return 1
- ;;
- esac
+ handler="prepare_template_render_var__$kind"
+ if ! declare -F "$handler" > /dev/null; then
+ config_error "unknown template render field kind $kind"
+ return 1
+ fi
+
+ render_value=''
+ "$handler" render_value "$context_name" "$source_name"
+ status=$?
+ if (( status != 0 )); then
+ return "$status"
+ fi
render_vars_ref["$render_var"]="$render_value"
done
diff --git a/tests/cli.sh b/tests/cli.sh
index 8727532..fdd2cec 100755
--- a/tests/cli.sh
+++ b/tests/cli.sh
@@ -3975,6 +3975,64 @@ BASH
fi
}
+# Open/Closed proof: render field kinds are dispatched by resolving a
+# prepare_template_render_var__<kind> handler by name. Adding a kind therefore
+# means only defining a new handler function -- the core loop never changes. We
+# assert (1) every kind in TEMPLATE_RENDER_FIELD_SPECS has a matching handler,
+# (2) defining a brand-new handler makes it resolvable and dispatch-able, and
+# (3) an unknown kind (no handler) is rejected as a config error.
+test_template_render_var_dispatch_is_extensible() {
+ local output
+
+ output=$(
+ bash -euo pipefail -s "$TEST_REPO_ROOT" <<'BASH'
+repo_root="$1"; shift
+
+# config_error (used for the unknown-kind path) lives in the validate lib.
+# shellcheck source=src/lib/config.validate.source.sh
+source "$repo_root/src/lib/config.validate.source.sh"
+# shellcheck source=src/lib/template.source.sh
+source "$repo_root/src/lib/template.source.sh"
+
+# Every declared kind must resolve to a handler function by name.
+missing=0
+for field_spec in "${TEMPLATE_RENDER_FIELD_SPECS[@]}"; do
+ IFS='|' read -r _ kind _ _ _ <<< "$field_spec"
+ if ! declare -F "prepare_template_render_var__$kind" > /dev/null; then
+ missing=1
+ fi
+done
+printf 'all_kinds_have_handlers=%s\n' "$([ "$missing" -eq 0 ] && echo yes || echo no)"
+
+# A brand-new kind, added purely by defining a handler function (OCP): the
+# dispatcher resolves and calls it without any change to the core loop.
+prepare_template_render_var__ocp_demo() {
+ local -n out_ref="$1"; shift
+ out_ref='dispatched'
+}
+demo_out=''
+handler="prepare_template_render_var__ocp_demo"
+declare -F "$handler" > /dev/null && "$handler" demo_out ctx ''
+printf 'demo=%s\n' "$demo_out"
+
+# An unknown kind has no handler, so the dispatcher must reject it.
+if declare -F "prepare_template_render_var__ocp_no_handler" > /dev/null; then
+ printf 'unknown=resolved\n'
+else
+ printf 'unknown=unresolved\n'
+fi
+BASH
+ )
+
+ if [ "$output" != \
+ $'all_kinds_have_handlers=yes\ndemo=dispatched\nunknown=unresolved' ]
+ then
+ printf 'FAIL: render var dispatch not extensible\n' >&2
+ printf 'actual:\n%s\n' "$output" >&2
+ exit 1
+ fi
+}
+
test_render_stats_page_renders_sections_and_escapes() {
local html
local output_file
@@ -5821,6 +5879,9 @@ main() {
'template required context vars come from render specs' \
test_template_required_context_vars_come_from_render_specs
test::run_case \
+ 'template render var dispatch is extensible (OCP)' \
+ test_template_render_var_dispatch_is_extensible
+ test::run_case \
'render_stats_page renders sections and escapes EXIF labels' \
test_render_stats_page_renders_sections_and_escapes
test::run_case \