diff options
| author | Paul Buetow <paul@buetow.org> | 2026-06-16 18:00:47 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-06-16 18:00:47 +0300 |
| commit | cd7cf797bc633700c5ee7e986077f366c000f0b9 (patch) | |
| tree | db7bda7af7a71359cba6bc6c0557552e8659fe8c | |
| parent | b58d9b3f656f2e6be803a1d2d567a066bb8b902a (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-x | bin/shuriken | 265 | ||||
| -rw-r--r-- | src/lib/template.source.sh | 265 | ||||
| -rwxr-xr-x | tests/cli.sh | 61 |
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 \ |
