From 381ba8f039cca1fd3c3c840b093e1f9b5aa078a3 Mon Sep 17 00:00:00 2001
From: Chris Hallberg <crhallberg@gmail.com>
Date: Fri, 13 Mar 2020 14:38:56 -0400
Subject: [PATCH] Slot View Helper (#1454)

* Slot helper prototype.

- Slot/block system for easier customization.
- Designed to set block values then include parent template (helper coming).
- start/end for buffer capture.
- set for string setting.
- clear for value clearing.

* Add get method to Slot.

* Add Slot method documentation.

* Improve Slot documentation.

* Change syntax to slot('name')->action(). Fix instancing.

* Improve documentation.

* Add examples to slot PR.

* Update documentation.

* checkstyles

* Fix error with slot->get.

* Remove trim so any value type can be saved.

* Add prepend/append methods to slots.

* Move Slots helper to VuFindTheme.

* Add unit tests for Slot helper.

* Clear Slot prepends and appends.

* Space and trim block output.

* checkstyle typo

* Improve documentation of non-string slot values.

* Add method constants.

* Add __invoke(name, val) and __toString shortcuts.

* Clarify prepend/append concatination.

* Slot clear returns old contents.

* Update test to match new clear functionality.

* Remove example

* get() can now return a default.

* Make non-string tests more robust.

* Remove hardcoded string.

* Documentation for get default.

* Apply slots to root templates.

* Add isset.

* Apply slots to bootstrap templates.

* Documentation fix.

* Checkstyles.

* Bugfix: prevent overriding of ''.

* Laminas migration.

* More specific slot name for home page hero.

* Remove 'footer' full footer slot (footer).

* Add tests to cover '' cases.

* Add a few default tests.

Co-authored-by: Demian Katz <demian.katz@villanova.edu>
---
 module/VuFindTheme/Module.php                 |   3 +
 .../src/VuFindTheme/View/Helper/Slot.php      | 259 ++++++++++++++
 .../src/VuFindTest/View/Helper/SlotTest.php   | 330 ++++++++++++++++++
 .../Recommend/CollectionSideFacets.phtml      |   2 +-
 .../templates/Recommend/SideFacets.phtml      |   2 +-
 .../Recommend/SideFacetsDeferred.phtml        |   2 +-
 .../templates/combined/results-list.phtml     |   6 +-
 .../templates/combined/results.phtml          |  15 +-
 themes/bootstrap3/templates/eds/search.phtml  |   2 +-
 themes/bootstrap3/templates/footer.phtml      |  44 ++-
 .../bootstrap3/templates/records/home.phtml   |   4 +-
 .../templates/search/controls/showing.phtml   |  28 +-
 themes/bootstrap3/templates/search/home.phtml |   8 +-
 .../templates/search/newitemresults.phtml     |   6 +-
 .../templates/search/reservesresults.phtml    |   8 +-
 .../bootstrap3/templates/search/results.phtml |  27 +-
 .../AbstractBase/export-endnote.phtml         |   2 +-
 .../AbstractBase/export-endnoteweb.phtml      |   2 +-
 .../AbstractBase/export-refworks.phtml        |   2 +-
 .../RecordDriver/Primo/export-endnote.phtml   |   2 +-
 .../RecordDriver/Primo/export-refworks.phtml  |   2 +-
 .../RecordDriver/Summon/export-endnote.phtml  |   2 +-
 .../RecordDriver/Summon/export-refworks.phtml |   2 +-
 23 files changed, 672 insertions(+), 88 deletions(-)
 create mode 100644 module/VuFindTheme/src/VuFindTheme/View/Helper/Slot.php
 create mode 100644 module/VuFindTheme/tests/unit-tests/src/VuFindTest/View/Helper/SlotTest.php

diff --git a/module/VuFindTheme/Module.php b/module/VuFindTheme/Module.php
index fd549f2dc81..2c1ead9cc65 100644
--- a/module/VuFindTheme/Module.php
+++ b/module/VuFindTheme/Module.php
@@ -100,6 +100,8 @@ class Module
                     View\Helper\ParentTemplateFactory::class,
                 View\Helper\InlineScript::class =>
                     View\Helper\PipelineInjectorFactory::class,
+                View\Helper\Slot::class =>
+                    View\Helper\PipelineInjectorFactory::class,
                 View\Helper\TemplatePath::class =>
                     View\Helper\TemplatePathFactory::class,
             ],
@@ -112,6 +114,7 @@ class Module
                 \Laminas\View\Helper\InlineScript::class =>
                     View\Helper\InlineScript::class,
                 'parentTemplate' => View\Helper\ParentTemplate::class,
+                'slot' => View\Helper\Slot::class,
                 'templatePath' => View\Helper\TemplatePath::class,
             ],
         ];
diff --git a/module/VuFindTheme/src/VuFindTheme/View/Helper/Slot.php b/module/VuFindTheme/src/VuFindTheme/View/Helper/Slot.php
new file mode 100644
index 00000000000..294e29a0c28
--- /dev/null
+++ b/module/VuFindTheme/src/VuFindTheme/View/Helper/Slot.php
@@ -0,0 +1,259 @@
+<?php
+/**
+ * Slot view helper
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2019.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  View_Helpers
+ * @author   Chris Hallberg <challber@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindTheme\View\Helper;
+
+/**
+ * Slot view helper
+ *
+ * @category VuFind
+ * @package  View_Helpers
+ * @author   Chris Hallberg <challber@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class Slot extends \Laminas\View\Helper\AbstractHelper
+{
+    /**
+     * End saving methods
+     *
+     * @const string
+     */
+    const SET   = 'SET';
+    const PREPEND = 'PREPEND';
+    const APPEND = 'APPEND';
+
+    /**
+     * Storage for strings to be concatinated to the front of a block
+     *
+     * @var array of arrays
+     */
+    protected $blockPrepends = [];
+
+    /**
+     * Storage for strings saved to slots
+     *
+     * @var array
+     */
+    protected $blocks = [];
+
+    /**
+     * Storage for strings to be concatinated to the end of a block
+     *
+     * @var array of arrays
+     */
+    protected $blockAppends = [];
+
+    /**
+     * Call stack to handle nested slots
+     *
+     * @var array
+     */
+    protected $stack = [];
+
+    /**
+     * Get the Slot instance. Create if instance doesn't exist.
+     *
+     * @param string $name  Name of target block for action
+     * @param any    $value Optional shortcut parameter to set a value
+     *
+     * @return Slot|string|any
+     */
+    public function __invoke($name, $value = null)
+    {
+        $this->stack[] = $name;
+        if ($value != null) {
+            return $this->set($value);
+        }
+        return $this;
+    }
+
+    /**
+     * Shortcut to get if no methods are called on invoke.
+     *
+     * @return string|any
+     */
+    public function __toString()
+    {
+        return $this->get();
+    }
+
+    /**
+     * Checks for content to provide isset functionality.
+     *
+     * @return boolean
+     */
+    public function isset()
+    {
+        $name = array_pop($this->stack);
+        return isset($this->blockPrepends[$name]) ||
+            isset($this->blocks[$name]) ||
+            isset($this->blockAppends[$name]);
+    }
+
+    /**
+     * Helper function to return blocks with prepends and appends.
+     * Prepends, blocks, and appends are separated byspacestopreventthisfromhappening
+     *
+     * Non-string data can be stored in a slot but prepend and append
+     * will cause it to be concatinated into a string.
+     *
+     * @param string $name Name of target block for action
+     *
+     * @return string|any
+     */
+    protected function build($name)
+    {
+        $pre = $this->blockPrepends[$name] ?? [];
+        $post = $this->blockAppends[$name] ?? [];
+        if (!empty($pre) || !empty($post)) {
+            $block = $this->blocks[$name] ?? '';
+            $ret = implode(' ', $pre) . ' ' . $block . ' ' . implode(' ', $post);
+            return trim($ret);
+        }
+        if (!isset($this->blocks[$name])) {
+            return null;
+        }
+        return $this->blocks[$name];
+    }
+
+    /**
+     * Get current value of slot. Returns null if unset.
+     *
+     * @param any $default Value to return if no value is set
+     *
+     * @return string|null
+     */
+    public function get($default = null)
+    {
+        $name = array_pop($this->stack);
+        $ret = $this->build($name);
+        return $ret === null ? $default : $ret;
+    }
+
+    /**
+     * Set current value of slot but only if unset.
+     *
+     * @param any $value Value to override if unset
+     *
+     * @return string|null
+     */
+    public function set($value)
+    {
+        $name = array_pop($this->stack);
+        if (!isset($this->blocks[$name])) {
+            $this->blocks[$name] = $value;
+        }
+        return $this->build($name);
+    }
+
+    /**
+     * Add string to list of block prepends.
+     *
+     * @param string $value Value to override if unset
+     *
+     * @return string
+     */
+    public function prepend($value)
+    {
+        $name = array_pop($this->stack);
+        if (!isset($this->blockPrepends[$name])) {
+            $this->blockPrepends[$name] = [$value];
+        } else {
+            array_unshift($this->blockPrepends[$name], $value);
+        }
+        return $this->build($name);
+    }
+
+    /**
+     * Add string to list of block appends.
+     *
+     * @param string $value Value to override if unset
+     *
+     * @return string
+     */
+    public function append($value)
+    {
+        $name = array_pop($this->stack);
+        if (!isset($this->blockAppends[$name])) {
+            $this->blockAppends[$name] = [$value];
+        } else {
+            array_push($this->blockAppends[$name], $value);
+        }
+        return $this->build($name);
+    }
+
+    /**
+     * Starts a buffer capture to override the value of a block.
+     *
+     * @return void
+     */
+    public function start()
+    {
+        array_pop($this->stack);
+        ob_start();
+    }
+
+    /**
+     * End a buffer capture to override the value of a block. Returns slot value.
+     *
+     * @param string $method SET/PREPEND/APPEND for where this buffer should be saved
+     *
+     * @return string|any
+     */
+    public function end($method = self::SET)
+    {
+        $method = strtoupper($method);
+        $ret = null;
+        if ($method == self::SET) {
+            $ret = $this->set(ob_get_contents());
+        } elseif ($method == self::PREPEND) {
+            $ret = $this->prepend(ob_get_contents());
+        } elseif ($method == self::APPEND) {
+            $ret = $this->append(ob_get_contents());
+        } else {
+            throw new \Exception("Undefined Slot method: $method");
+        }
+        ob_end_clean();
+        return $ret;
+    }
+
+    /**
+     * Unset any values stored in a slot.
+     *
+     * @return void
+     */
+    public function clear()
+    {
+        $name = array_pop($this->stack);
+        $ret = $this->build($name);
+        unset($this->blockPrepends[$name]);
+        unset($this->blocks[$name]);
+        unset($this->blockAppends[$name]);
+        return $ret;
+    }
+}
diff --git a/module/VuFindTheme/tests/unit-tests/src/VuFindTest/View/Helper/SlotTest.php b/module/VuFindTheme/tests/unit-tests/src/VuFindTest/View/Helper/SlotTest.php
new file mode 100644
index 00000000000..8d92883a3e4
--- /dev/null
+++ b/module/VuFindTheme/tests/unit-tests/src/VuFindTest/View/Helper/SlotTest.php
@@ -0,0 +1,330 @@
+<?php
+/**
+ * HeadThemeResources view helper Test Class
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2010.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\View\Helper;
+
+use VuFindTheme\ResourceContainer;
+use VuFindTheme\View\Helper\Slot;
+
+/**
+ * HeadThemeResources view helper Test Class
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class SlotTest extends \VuFindTest\Unit\TestCase
+{
+    /**
+     * Test the helper.
+     *
+     * @return void
+     */
+    public function testInstance()
+    {
+        $helper = $this->getHelper();
+        $ret = $helper->__invoke('test');
+        $this->assertTrue($ret instanceof Slot);
+    }
+
+    /**
+     * Test get value of slot.
+     *
+     * @return void
+     */
+    public function testGet()
+    {
+        $helper = $this->getHelper();
+
+        // test empty default
+        $this->assertEquals(null, $helper->__invoke('test')->get());
+        $this->assertEquals('default', $helper->__invoke('test')->get('default'));
+
+        // test populated over default
+        $helper->__invoke('test')->set('ONE');
+        $this->assertEquals('ONE', $helper->__invoke('test')->get());
+        $this->assertEquals('ONE', $helper->__invoke('test')->get('default'));
+    }
+
+    /**
+     * Test setting value of slot blocking later sets.
+     *
+     * @return void
+     */
+    public function testSet()
+    {
+        $helper = $this->getHelper();
+
+        // test return
+        $ret = $helper->__invoke('test')->set('ONE');
+        $this->assertEquals('ONE', $ret);
+
+        // test get
+        $this->assertEquals('ONE', $helper->__invoke('test')->get());
+
+        // test no override
+        $ret = $helper->__invoke('test')->set('TWO');
+        $this->assertEquals('ONE', $ret);
+
+        // test number
+        $ret = $helper->__invoke('array')->set(100);
+        $this->assertEquals(100, $ret);
+
+        // test empty string (not null)
+        $helper->__invoke('empty')->clear();
+        $ret = $helper->__invoke('empty')->set('');
+        $this->assertEquals('', $ret);
+        $this->assertEquals('', $helper->__invoke('empty')->get('default'));
+
+        // test array
+        $helper->__invoke('array')->clear();
+        $ret = $helper->__invoke('array')->set([1, 2, 3]);
+        $this->assertEquals([1, 2, 3], $ret);
+
+        // test object
+        $helper->__invoke('array')->clear();
+        $ret = $helper->__invoke('array')->set(new \SplStack());
+        $this->assertEquals('SplStack', get_class($ret));
+
+        // test shortcuts
+        $ret = $helper->__invoke('short', 'SUCCESS');
+        $this->assertEquals('SUCCESS', $ret);
+        $this->assertEquals('SUCCESS', $helper->__invoke('short'));
+    }
+
+    /**
+     * Test capturing echo with start and end.
+     *
+     * @return void
+     */
+    public function testCapture()
+    {
+        $helper = $this->getHelper();
+
+        // test capture
+        $helper->__invoke('test')->start();
+        echo 'BUFFER';
+        $ret = $helper->__invoke('test')->end();
+        $this->assertEquals('BUFFER', $ret);
+
+        // test no override
+        $helper->__invoke('test')->start();
+        echo 'OVERRIDE';
+        $ret = $helper->__invoke('test')->end();
+        $this->assertEquals('BUFFER', $ret);
+    }
+
+    /**
+     * Test clearing blocks and allowing for override.
+     *
+     * @return void
+     */
+    public function testClear()
+    {
+        $helper = $this->getHelper();
+        $set1 = $helper->__invoke('test')->set('ONE');
+        $this->assertEquals('ONE', $set1);
+
+        $ret = $helper->__invoke('test')->clear();
+
+        // test returns old content
+        $this->assertEquals('ONE', $ret);
+
+        // test now null
+        $this->assertEquals(null, $helper->__invoke('test')->get());
+
+        // test set after clear
+        $set1 = $helper->__invoke('test')->set('TWO');
+        $this->assertEquals('TWO', $set1);
+    }
+
+    /**
+     * Test prepending more to blocks.
+     *
+     * @return void
+     */
+    public function testPrepend()
+    {
+        $helper = $this->getHelper();
+
+        // test no block
+        $ret = $helper->__invoke('test')->prepend('PRE1');
+        $this->assertEquals('PRE1', $ret);
+        // default only returns of all are unset
+        $this->assertEquals('PRE1', $helper->__invoke('test')->get('default'));
+
+        // test with block
+        $ret = $helper->__invoke('test')->set('BLOCK');
+        $this->assertEquals('PRE1 BLOCK', $ret);
+
+        // test capture prepend
+        $helper->__invoke('test')->start();
+        echo 'PRE2';
+        $ret = $helper->__invoke('test')->end('PREPEND'); // end mode
+        $this->assertEquals('PRE2 PRE1 BLOCK', $ret);
+
+        // test get
+        $this->assertEquals('PRE2 PRE1 BLOCK', $helper->__invoke('test')->get());
+
+        // test clear
+        $helper->__invoke('test')->clear();
+        $this->assertEquals(null, $helper->__invoke('test')->get());
+
+        // test empty strings
+        $ret = $helper->__invoke('test')->set('');
+        $ret = $helper->__invoke('test')->prepend('PRE1');
+        $this->assertEquals('PRE1', $ret);
+        $helper->__invoke('test')->clear();
+        $ret = $helper->__invoke('test')->set('BASE');
+        $ret = $helper->__invoke('test')->prepend('');
+        $this->assertEquals('BASE', $ret);
+    }
+
+    /**
+     * Test appending more to blocks.
+     *
+     * @return void
+     */
+    public function testAppend()
+    {
+        $helper = $this->getHelper();
+
+        // test no block
+        $ret = $helper->__invoke('test')->append('POST1');
+        $this->assertEquals('POST1', $ret);
+        // default only returns of all are unset
+        $this->assertEquals('POST1', $helper->__invoke('test')->get('default'));
+
+        // test with block
+        $ret = $helper->__invoke('test')->set('BLOCK');
+        $this->assertEquals('BLOCK POST1', $ret);
+
+        // test capture append
+        $helper->__invoke('test')->start();
+        echo 'POST2';
+        $ret = $helper->__invoke('test')->end('APPEND'); // end mode
+        $this->assertEquals('BLOCK POST1 POST2', $ret);
+
+        // test get
+        $this->assertEquals('BLOCK POST1 POST2', $helper->__invoke('test')->get());
+
+        // test clear
+        $helper->__invoke('test')->clear();
+        $this->assertEquals(null, $helper->__invoke('test')->get());
+
+        // test empty strings
+        $ret = $helper->__invoke('test')->set('');
+        $ret = $helper->__invoke('test')->append('POST');
+        $this->assertEquals('POST', $ret);
+        $helper->__invoke('test')->clear();
+        $ret = $helper->__invoke('test')->set('BASE');
+        $ret = $helper->__invoke('test')->append('');
+        $this->assertEquals('BASE', $ret);
+    }
+
+    /**
+     * Test nested slots.
+     *
+     * @return void
+     */
+    public function testNesting()
+    {
+        $helper = $this->getHelper();
+
+        $helper->__invoke('parent')->start();
+        echo '<parent>';
+
+        $helper->__invoke('child')->start();
+        echo 'CHILD';
+        echo $child = $helper->__invoke('child')->end();
+        $this->assertEquals('CHILD', $child);
+
+        echo '</parent>';
+        $ret = $helper->__invoke('parent')->end();
+        $this->assertEquals('<parent>CHILD</parent>', $ret);
+    }
+
+    /**
+     * Test nested slots showing that children don't appear in parent without echo.
+     *
+     * @return void
+     */
+    public function testNestingWithoutEcho()
+    {
+        $helper = $this->getHelper();
+
+        $helper->__invoke('parent')->start();
+        echo '<parent>';
+
+        $helper->__invoke('child')->start();
+        echo 'CHILD';
+        $child = $helper->__invoke('child')->end(); // no echo
+        $this->assertEquals('CHILD', $child);
+
+        echo '</parent>';
+        $ret = $helper->__invoke('parent')->end();
+        $this->assertEquals('<parent></parent>', $ret);
+    }
+
+    /**
+     * Build Slot helper with mock view
+     *
+     * @return \VuFindTheme\View\Helper\Slot
+     */
+    protected function getHelper()
+    {
+        $helper = new Slot($this->getResourceContainer());
+        $helper->setView($this->getMockView());
+        return $helper;
+    }
+
+    /**
+     * Get a populated resource container for testing.
+     *
+     * @return ResourceContainer
+     */
+    protected function getResourceContainer()
+    {
+        $rc = new ResourceContainer();
+        $rc->setEncoding('utf-8');
+        $rc->setGenerator('fake-generator');
+        return $rc;
+    }
+
+    /**
+     * Get a fake view object.
+     *
+     * @return \Laminas\View\Renderer\PhpRenderer
+     */
+    protected function getMockView()
+    {
+        $view = $this->createMock(\Laminas\View\Renderer\PhpRenderer::class);
+        return $view;
+    }
+}
diff --git a/themes/bootstrap3/templates/Recommend/CollectionSideFacets.phtml b/themes/bootstrap3/templates/Recommend/CollectionSideFacets.phtml
index 41bcc4f0abc..d33647d61d8 100644
--- a/themes/bootstrap3/templates/Recommend/CollectionSideFacets.phtml
+++ b/themes/bootstrap3/templates/Recommend/CollectionSideFacets.phtml
@@ -1,5 +1,5 @@
 <?php
-    $this->overrideSideFacetCaption = 'Filter Collection';
+    $this->slot('side-facet-caption')->set('Filter Collection');
     $this->baseUriExtra = $this->recommend->getResults()->getParams()->getCollectionId();
 ?>
 <?=$this->render('Recommend/SideFacets.phtml')?>
diff --git a/themes/bootstrap3/templates/Recommend/SideFacets.phtml b/themes/bootstrap3/templates/Recommend/SideFacets.phtml
index 7817bff86e9..d89fce8f6e6 100644
--- a/themes/bootstrap3/templates/Recommend/SideFacets.phtml
+++ b/themes/bootstrap3/templates/Recommend/SideFacets.phtml
@@ -28,7 +28,7 @@
 ?>
 <button class="close-offcanvas btn btn-link" data-toggle="offcanvas"><?=$this->transEsc('navigate_back') ?></button>
 <?php if ($results->getResultTotal() > 0): ?>
-  <h2><?=$this->transEsc(isset($this->overrideSideFacetCaption) ? $this->overrideSideFacetCaption : 'Narrow Search')?></h2>
+  <h4><?=$this->transEsc($this->slot('side-facet-caption')->get('Narrow Search')) ?></h4>
 <?php endif; ?>
 <?php $checkboxFilters = $this->recommend->getCheckboxFacetSet(); ?>
 <?php $checkboxesShown = false; ?>
diff --git a/themes/bootstrap3/templates/Recommend/SideFacetsDeferred.phtml b/themes/bootstrap3/templates/Recommend/SideFacetsDeferred.phtml
index 3d7e3f310de..69be1ea1239 100644
--- a/themes/bootstrap3/templates/Recommend/SideFacetsDeferred.phtml
+++ b/themes/bootstrap3/templates/Recommend/SideFacetsDeferred.phtml
@@ -29,7 +29,7 @@
   }
 ?>
 <?php if ($results->getResultTotal() > 0): ?>
-  <h2><?=$this->transEsc(isset($this->overrideSideFacetCaption) ? $this->overrideSideFacetCaption : 'Narrow Search')?></h2>
+  <h4><?=$this->transEsc($this->slot('side-facet-caption')->get('Narrow Search')) ?></h4>
   <div class="side-facets-container-ajax" data-search-class-id="<?=$this->escapeHtmlAttr($this->searchClassId) ?>" data-location="<?=$this->escapeHtmlAttr($this->location) ?>" data-config-index="<?=$this->escapeHtmlAttr($this->configIndex) ?>">
 <?php endif; ?>
 <?php $checkboxFilters = $this->recommend->getCheckboxFacetSet(); ?>
diff --git a/themes/bootstrap3/templates/combined/results-list.phtml b/themes/bootstrap3/templates/combined/results-list.phtml
index f53707e5a2e..99a0a95058d 100644
--- a/themes/bootstrap3/templates/combined/results-list.phtml
+++ b/themes/bootstrap3/templates/combined/results-list.phtml
@@ -37,11 +37,7 @@
 
 <?php if ($recordTotal < 1): ?>
   <p class="alert alert-danger">
-    <?php if (isset($view->overrideEmptyMessage)): ?>
-      <?=$view->overrideEmptyMessage?>
-    <?php else: ?>
-      <?=$this->translate('nohit_lookfor_html', ['%%lookfor%%' => $this->escapeHtml($lookfor)]) ?>
-    <?php endif; ?>
+    <?=$this->slot('empty-message')->get($this->translate('nohit_lookfor_html', ['%%lookfor%%' => $this->escapeHtml($lookfor)])); ?>
   </p>
   <?php if (isset($view->parseError)): ?>
     <p class="alert alert-danger"><?=$this->transEsc('nohit_parse_error')?></p>
diff --git a/themes/bootstrap3/templates/combined/results.phtml b/themes/bootstrap3/templates/combined/results.phtml
index 9bdc276a54f..23b2a079812 100644
--- a/themes/bootstrap3/templates/combined/results.phtml
+++ b/themes/bootstrap3/templates/combined/results.phtml
@@ -1,11 +1,9 @@
 <?php
   // Set up page title:
   $lookfor = $this->params->getDisplayQuery();
-  if (isset($this->overrideTitle)) {
-    $this->headTitle($this->overrideTitle);
-  } else {
-      $this->headTitle($this->translate('Search Results') . (empty($lookfor) ? '' : " - {$lookfor}"));
-  }
+  $headTitle = $this->slot('head-title')
+        ->get($this->translate('Search Results') . (empty($lookfor) ? '' : " - {$lookfor}"));
+  $this->headTitle($headTitle);
 
   // Set up search box:
   $this->layout()->searchbox = $this->context($this)->renderInContext(
@@ -27,12 +25,7 @@
   $combinedResults = $this->results;
 
   // Set up breadcrumbs:
-  if (isset($this->overrideTitle)) {
-    $this->layout()->breadcrumbs = '<li class="active">' . $this->escapeHtml($this->overrideTitle) . '</li>';
-  } else {
-    $this->layout()->breadcrumbs = '<li class="active">' . $this->transEsc('Search') . ': ' .
-      $this->escapeHtml($lookfor) . '</li>';
-  }
+  $this->layout()->breadcrumbs = '<li class="active">' . $this->escapeHtml($headTitle) . '</li>';
 
   // Enable cart if appropriate:
   $this->showCartControls = $this->supportsCart && $this->cart()->isActive();
diff --git a/themes/bootstrap3/templates/eds/search.phtml b/themes/bootstrap3/templates/eds/search.phtml
index 772edf01d51..d405df2d979 100644
--- a/themes/bootstrap3/templates/eds/search.phtml
+++ b/themes/bootstrap3/templates/eds/search.phtml
@@ -1,6 +1,6 @@
 <?php
   // Load standard settings from the default search results screen:
-  $this->overrideSideFacetCaption = 'Refine Results';
+  $this->slot('side-facet-caption')->set('Refine Results');
   $this->paginationOptions = ['disableFirst' => true, 'disableLast' => true];
   echo $this->render('search/results.phtml');
 ?>
diff --git a/themes/bootstrap3/templates/footer.phtml b/themes/bootstrap3/templates/footer.phtml
index 2604172f5de..4a5d3c89682 100644
--- a/themes/bootstrap3/templates/footer.phtml
+++ b/themes/bootstrap3/templates/footer.phtml
@@ -1,29 +1,35 @@
 <footer class="hidden-print">
   <div class="footer-container">
     <div class="footer-column">
-      <h2><?=$this->transEsc('Search Options')?></h2>
-      <ul>
-        <li><a href="<?=$this->url('search-history')?>"><?=$this->transEsc('Search History')?></a></li>
-        <li><a href="<?=$this->url('search-advanced')?>"><?=$this->transEsc('Advanced Search')?></a></li>
-      </ul>
+      <?php $this->slot('footer-left')->start(); ?>
+        <p><strong><?=$this->transEsc('Search Options')?></strong></p>
+        <ul>
+          <li><a href="<?=$this->url('search-history')?>"><?=$this->transEsc('Search History')?></a></li>
+          <li><a href="<?=$this->url('search-advanced')?>"><?=$this->transEsc('Advanced Search')?></a></li>
+        </ul>
+      <?=$this->slot('footer-left')->end(); ?>
     </div>
     <div class="footer-column">
-      <h2><?=$this->transEsc('Find More')?></h2>
-      <ul>
-        <li><a href="<?=$this->url('browse-home')?>"><?=$this->transEsc('Browse the Catalog')?></a></li>
-        <li><a href="<?=$this->url('alphabrowse-home')?>"><?=$this->transEsc('Browse Alphabetically')?></a></li>
-        <li><a href="<?=$this->url('channels-home')?>"><?=$this->transEsc('channel_explore')?></a></li>
-        <li><a href="<?=$this->url('search-reserves')?>"><?=$this->transEsc('Course Reserves')?></a></li>
-        <li><a href="<?=$this->url('search-newitem')?>"><?=$this->transEsc('New Items')?></a></li>
-      </ul>
+      <?php $this->slot('footer-center')->start(); ?>
+        <p><strong><?=$this->transEsc('Find More')?></strong></p>
+        <ul>
+          <li><a href="<?=$this->url('browse-home')?>"><?=$this->transEsc('Browse the Catalog')?></a></li>
+          <li><a href="<?=$this->url('alphabrowse-home')?>"><?=$this->transEsc('Browse Alphabetically')?></a></li>
+          <li><a href="<?=$this->url('channels-home')?>"><?=$this->transEsc('channel_explore')?></a></li>
+          <li><a href="<?=$this->url('search-reserves')?>"><?=$this->transEsc('Course Reserves')?></a></li>
+          <li><a href="<?=$this->url('search-newitem')?>"><?=$this->transEsc('New Items')?></a></li>
+        </ul>
+      <?=$this->slot('footer-center')->end(); ?>
     </div>
     <div class="footer-column">
-      <h2><?=$this->transEsc('Need Help?')?></h2>
-      <ul>
-        <li><a href="<?=$this->url('help-home')?>?topic=search&amp;_=<?=time() ?>" data-lightbox class="help-link"><?=$this->transEsc('Search Tips')?></a></li>
-        <li><a href="<?=$this->url('content-page', ['page' => 'asklibrary']) ?>"><?=$this->transEsc('Ask a Librarian')?></a></li>
-        <li><a href="<?=$this->url('content-page', ['page' => 'faq']) ?>"><?=$this->transEsc('FAQs')?></a></li>
-      </ul>
+      <?php $this->slot('footer-right')->start(); ?>
+        <p><strong><?=$this->transEsc('Need Help?')?></strong></p>
+        <ul>
+          <li><a href="<?=$this->url('help-home')?>?topic=search&amp;_=<?=time() ?>" data-lightbox class="help-link"><?=$this->transEsc('Search Tips')?></a></li>
+          <li><a href="<?=$this->url('content-page', ['page' => 'asklibrary']) ?>"><?=$this->transEsc('Ask a Librarian')?></a></li>
+          <li><a href="<?=$this->url('content-page', ['page' => 'faq']) ?>"><?=$this->transEsc('FAQs')?></a></li>
+        </ul>
+      <?=$this->slot('footer-right')->end(); ?>
     </div>
   </div>
   <div class="poweredby">
diff --git a/themes/bootstrap3/templates/records/home.phtml b/themes/bootstrap3/templates/records/home.phtml
index e8e558779b4..0136ff42352 100644
--- a/themes/bootstrap3/templates/records/home.phtml
+++ b/themes/bootstrap3/templates/records/home.phtml
@@ -1,6 +1,6 @@
 <?php
-    $this->overrideTitle = $this->translate('View Records');
-    $this->overrideSearchHeading = '';
+    $this->slot('head-title')->set($this->translate('View Records'));
+    $this->slot('search-heading')->set('');
 
     // Load standard settings from the default search results screen:
     echo $this->render('search/results.phtml');
diff --git a/themes/bootstrap3/templates/search/controls/showing.phtml b/themes/bootstrap3/templates/search/controls/showing.phtml
index b0ae18b0c94..b2598bb229b 100644
--- a/themes/bootstrap3/templates/search/controls/showing.phtml
+++ b/themes/bootstrap3/templates/search/controls/showing.phtml
@@ -5,21 +5,21 @@
     '%%total%%' => $this->localizedNumber($this->recordTotal),
     '%%lookfor%%' => $this->escapeHtml($this->lookfor)
   ];
+
+  $showingResults = $this->translate(
+    isset($this->skipTotalCount) ? 'showing_results_html' : 'showing_results_of_html',
+    $transParams
+  );
 ?>
-<?php if (!isset($this->skipTotalCount)): ?>
-  <?php $showingResults = $this->translate('showing_results_of_html', $transParams); ?>
-<?php else: ?>
-  <?php $showingResults = $this->translate('showing_results_html', $transParams); ?>
-<?php endif; ?>
-<?php if (isset($this->overrideSearchHeading)): ?>
-  <?php $showingResults .= ' ' . $this->overrideSearchHeading; ?>
-<?php elseif ($this->params->getSearchType() == 'basic'): ?>
-  <?php if (!isset($this->skipTotalCount)): ?>
-    <?php $showingResults = $this->translate('showing_results_of_for_html', $transParams); ?>
-  <?php else: ?>
-    <?php $showingResults = $this->translate('showing_results_for_html', $transParams); ?>
-  <?php endif; ?>
-<?php endif; ?>
+<?php if ($this->slot('search-heading')->isset()): ?>
+  <?php $showingResults .= ' ' . $this->slot('search-heading')->get(); ?>
+<?php elseif ($this->params->getSearchType() == 'basic'):
+  $showingResults = $this->translate(
+    isset($this->skipTotalCount) ? 'showing_results_for_html' : 'showing_results_of_for_html',
+    $transParams
+  );
+endif; ?>
+
 <?php $this->layout()->srmessage = $showingResults; ?>
 <?php if ($qtime = $this->results->getQuerySpeed()): ?>
   <?=$showingResults; ?><span class="search-query-time">, <?=$this->transEsc('query time')?>: <?=$this->localizedNumber($qtime, 2) . $this->transEsc('seconds_abbrev')?></span>
diff --git a/themes/bootstrap3/templates/search/home.phtml b/themes/bootstrap3/templates/search/home.phtml
index ee530a73b3e..b9b0c453f4f 100644
--- a/themes/bootstrap3/templates/search/home.phtml
+++ b/themes/bootstrap3/templates/search/home.phtml
@@ -14,8 +14,10 @@
 ?>
 
 <div class="searchHomeContent">
-  <?=$this->context($this)->renderInContext("search/searchbox.phtml", ['ignoreHiddenFilterMemory' => true])?>
-  <?=$this->inlineScript(\Laminas\View\Helper\HeadScript::SCRIPT, '$("#searchForm_lookfor").focus();', 'SET'); ?>
+  <?php $this->slot('search-home-hero')->start() ?>
+    <?=$this->context($this)->renderInContext("search/searchbox.phtml", ['ignoreHiddenFilterMemory' => true])?>
+    <?=$this->inlineScript(\Laminas\View\Helper\HeadScript::SCRIPT, '$("#searchForm_lookfor").focus();', 'SET'); ?>
+  <?=$this->slot('search-home')->end() ?>
 </div>
 
-<?=implode('', array_map([$this, 'contentBlock'], $blocks ?? []))?>
\ No newline at end of file
+<?=implode('', array_map([$this, 'contentBlock'], $blocks ?? []))?>
diff --git a/themes/bootstrap3/templates/search/newitemresults.phtml b/themes/bootstrap3/templates/search/newitemresults.phtml
index 8eb629497fd..4a61b2ad29d 100644
--- a/themes/bootstrap3/templates/search/newitemresults.phtml
+++ b/themes/bootstrap3/templates/search/newitemresults.phtml
@@ -1,7 +1,7 @@
 <?php
   // Set some overrides, then call the standard search results action:
-  $this->overrideTitle = $this->translate('New Items');
-  $this->overrideSearchHeading = $this->transEsc('New Items');
-  $this->overrideEmptyMessage = $this->transEsc('No new item information is currently available.');
+  $this->slot('head-title')->set($this->translate('New Items'));
+  $this->slot('search-heading')->set($this->transEsc('New Items'));
+  $this->slot('empty-message')->set($this->transEsc('No new item information is currently available.'));
   echo $this->render('search/results.phtml');
 ?>
diff --git a/themes/bootstrap3/templates/search/reservesresults.phtml b/themes/bootstrap3/templates/search/reservesresults.phtml
index d007c4f582e..cdcf2affa73 100644
--- a/themes/bootstrap3/templates/search/reservesresults.phtml
+++ b/themes/bootstrap3/templates/search/reservesresults.phtml
@@ -1,7 +1,7 @@
 <?php
     // Set some overrides, then call the standard search results action:
-    $this->overrideTitle = $this->translate('Reserves Search Results');
-    $this->overrideSearchHeading = $this->transEsc('Reserves');
+    $this->slot('head-title')->set($this->translate('Reserves Search Results'));
+    $this->slot('search-heading')->set($this->transEsc('Reserves'));
     $headingParts = [];
     if (isset($this->instructor)) {
         $headingParts[] = $this->transEsc('Instructor') . ': <strong>' . $this->escapeHtml($this->instructor) . '</strong>';
@@ -10,8 +10,8 @@
         $headingParts[] = $this->transEsc('Course') . ': <strong>' . $this->escapeHtml($this->course) . '</strong>';
     }
     if (!empty($headingParts)) {
-        $this->overrideSearchHeading .= ' (' . implode(', ', $headingParts) . ')';
+        $this->slot('search-heading')->append(' (' . implode(', ', $headingParts) . ')');
     }
-    $this->overrideEmptyMessage = $this->transEsc('course_reserves_empty_list');
+    $this->slot('empty-message')->set($this->transEsc('course_reserves_empty_list'));
     echo $this->render('search/results.phtml');
 ?>
diff --git a/themes/bootstrap3/templates/search/results.phtml b/themes/bootstrap3/templates/search/results.phtml
index bec5c3c6dbe..dbf10432e07 100644
--- a/themes/bootstrap3/templates/search/results.phtml
+++ b/themes/bootstrap3/templates/search/results.phtml
@@ -1,11 +1,9 @@
 <?php
   // Set up page title:
   $lookfor = $this->results->getUrlQuery()->isQuerySuppressed() ? '' : $this->params->getDisplayQuery();
-  if (isset($this->overrideTitle)) {
-      $this->headTitle($this->overrideTitle);
-  } else {
-      $this->headTitle($this->translate('Search Results') . (empty($lookfor) ? '' : " - {$lookfor}"));
-  }
+  $headTitle = $this->slot('head-title')
+        ->get($this->translate('Search Results') . (empty($lookfor) ? '' : " - {$lookfor}"));
+  $this->headTitle($headTitle);
 
   // Set up search box:
   $this->layout()->searchbox = $this->context($this)->renderInContext(
@@ -26,11 +24,7 @@
   );
 
   // Set up breadcrumbs:
-  if (isset($this->overrideTitle)) {
-    $this->layout()->breadcrumbs .= '<li class="active">' . $this->escapeHtml($this->overrideTitle) . '</li>';
-  } else {
-    $this->layout()->breadcrumbs .= '<li class="active">' . $this->transEsc('Search') . ': ' . $this->escapeHtml($lookfor) . '</li>';
-  }
+  $this->layout()->breadcrumbs .= '<li class="active">' . $this->escapeHtml($headTitle) . '</li>';
 
   // Enable cart if appropriate:
   $this->showBulkOptions = $this->params->getOptions()->supportsCart() && $this->showBulkOptions;
@@ -83,12 +77,13 @@
 
   <?php if ($recordTotal < 1): ?>
     <p>
-      <?php if (isset($this->overrideEmptyMessage)): ?>
-        <?=$this->overrideEmptyMessage?>
-      <?php else: ?>
-        <?php $this->layout()->srmessage = $this->translate('nohit_lookfor_html', ['%%lookfor%%' => $this->escapeHtml($lookfor)]); ?>
-        <?=$this->layout()->srmessage ?>
-      <?php endif; ?>
+      <?php
+        $emptyMessage = $this->slot('empty-message')->get(
+          $this->translate('nohit_lookfor_html', ['%%lookfor%%' => $this->escapeHtml($lookfor)])
+        );
+        $this->layout()->srmessage = $emptyMessage;
+        echo $emptyMessage;
+      ?>
     </p>
     <?php if (isset($this->parseError)): ?>
       <p class="alert alert-danger"><?=$this->transEsc('nohit_parse_error')?></p>
diff --git a/themes/root/templates/RecordDriver/AbstractBase/export-endnote.phtml b/themes/root/templates/RecordDriver/AbstractBase/export-endnote.phtml
index 614f668d251..fcbd238271d 100644
--- a/themes/root/templates/RecordDriver/AbstractBase/export-endnote.phtml
+++ b/themes/root/templates/RecordDriver/AbstractBase/export-endnote.phtml
@@ -1,6 +1,6 @@
 <?php
 // A driver-specific template may pass in format overrides; check for these before going to the driver itself:
-$formats = isset($this->overrideFormats) ? $this->overrideFormats : $this->driver->tryMethod('getFormats');
+$formats = $this->slot('endnote-formats')->get($this->driver->tryMethod('getFormats'));
 if (is_array($formats) && !empty($formats)) {
     foreach ($formats as $format) {
         echo "%0 $format\n";
diff --git a/themes/root/templates/RecordDriver/AbstractBase/export-endnoteweb.phtml b/themes/root/templates/RecordDriver/AbstractBase/export-endnoteweb.phtml
index c3f86e304bf..0b9877d8d78 100644
--- a/themes/root/templates/RecordDriver/AbstractBase/export-endnoteweb.phtml
+++ b/themes/root/templates/RecordDriver/AbstractBase/export-endnoteweb.phtml
@@ -1,6 +1,6 @@
 <?php
 // A driver-specific template may pass in format overrides; check for these before going to the driver itself:
-$formats = isset($this->overrideFormats) ? $this->overrideFormats : $this->driver->tryMethod('getFormats');
+$formats = $this->slot('endnoteweb-formats')->get($this->driver->tryMethod('getFormats'));
 if (is_array($formats)) {
     foreach ($formats as $format) {
         switch (strtolower($format)) {
diff --git a/themes/root/templates/RecordDriver/AbstractBase/export-refworks.phtml b/themes/root/templates/RecordDriver/AbstractBase/export-refworks.phtml
index 83bdd8559c9..40c406eea4b 100644
--- a/themes/root/templates/RecordDriver/AbstractBase/export-refworks.phtml
+++ b/themes/root/templates/RecordDriver/AbstractBase/export-refworks.phtml
@@ -1,6 +1,6 @@
 <?php
 // A driver-specific template may pass in format overrides; check for these before going to the driver itself:
-$formats = isset($this->overrideFormats) ? $this->overrideFormats : $this->driver->tryMethod('getFormats');
+$formats = $this->slot('refworks-formats')->get($this->driver->tryMethod('getFormats'));
 if (is_array($formats) && !empty($formats)) {
     foreach ($formats as $format) {
         echo "RT $format\n";
diff --git a/themes/root/templates/RecordDriver/Primo/export-endnote.phtml b/themes/root/templates/RecordDriver/Primo/export-endnote.phtml
index 6a69596db1f..927f9c7cdb9 100644
--- a/themes/root/templates/RecordDriver/Primo/export-endnote.phtml
+++ b/themes/root/templates/RecordDriver/Primo/export-endnote.phtml
@@ -61,6 +61,6 @@ case 'Website':
     break;
 }
 
-$this->overrideFormats = [$endnoteFormat];
+$this->slot('endnote-formats')->set([$endnoteFormat]);
 // Use the default template, but override the formats:
 echo $this->render('RecordDriver/AbstractBase/export-endnote.phtml');
diff --git a/themes/root/templates/RecordDriver/Primo/export-refworks.phtml b/themes/root/templates/RecordDriver/Primo/export-refworks.phtml
index c4904e8259b..0b31ae9cf27 100644
--- a/themes/root/templates/RecordDriver/Primo/export-refworks.phtml
+++ b/themes/root/templates/RecordDriver/Primo/export-refworks.phtml
@@ -57,6 +57,6 @@ case 'Website':
     break;
 }
 
-$this->overrideFormats = [$refworksFormat];
+$this->slot('refworks-formats')->set([$refworksFormat]);
 // Use the default template, but override the formats:
 echo $this->render('RecordDriver/AbstractBase/export-refworks.phtml');
diff --git a/themes/root/templates/RecordDriver/Summon/export-endnote.phtml b/themes/root/templates/RecordDriver/Summon/export-endnote.phtml
index e733fdfec86..8e46b18e62f 100644
--- a/themes/root/templates/RecordDriver/Summon/export-endnote.phtml
+++ b/themes/root/templates/RecordDriver/Summon/export-endnote.phtml
@@ -6,6 +6,6 @@ foreach ($formats as $i => $format) {
 }
 
 // Use the default template, but override the formats:
-$this->overrideFormats = $formats;
+$this->slot('endnote-formats')->set($formats);
 echo $this->render('RecordDriver/AbstractBase/export-endnote.phtml');
 ?>
diff --git a/themes/root/templates/RecordDriver/Summon/export-refworks.phtml b/themes/root/templates/RecordDriver/Summon/export-refworks.phtml
index 008f016744e..ff9a0878c9a 100644
--- a/themes/root/templates/RecordDriver/Summon/export-refworks.phtml
+++ b/themes/root/templates/RecordDriver/Summon/export-refworks.phtml
@@ -6,6 +6,6 @@ foreach ($formats as $i => $format) {
 }
 
 // Use the default template, but override the formats:
-$this->overrideFormats = $formats;
+$this->slot('refworks-formats')->set($formats);
 echo $this->render('RecordDriver/AbstractBase/export-refworks.phtml');
 ?>
-- 
GitLab