diff --git a/composer.json b/composer.json
index 5d6d84cec4b2a9a939b2a76fc7c08ad9676b35c1..28eac3accac6dc4ad77ba972b3a086b598c0121e 100644
--- a/composer.json
+++ b/composer.json
@@ -129,7 +129,7 @@
         "drupal/focal_point": "1.0-beta6",
         "drupal/geolocation": "1.10",
         "drupal/google_analytics": "3.0",
-        "drupal/google_tag": "^1.1",
+        "drupal/google_tag": "^1.3",
         "drupal/honeypot": "^1.28",
         "drupal/image_popup": "1.1",
         "drupal/inline_entity_form": "1.0-rc1",
diff --git a/composer.lock b/composer.lock
index e7f323707388f25226e962b1492bad0641f95d2d..2ad388a59d7e1448d827f48998ba6e0988e2a40a 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "9c3077b4cca6c29f375aa02405c58ae2",
+    "content-hash": "b6620065182be6bb85da3f6c9b1102e7",
     "packages": [
         {
             "name": "alchemy/zippy",
@@ -5112,17 +5112,17 @@
         },
         {
             "name": "drupal/google_tag",
-            "version": "1.1.0",
+            "version": "1.3.0",
             "source": {
                 "type": "git",
                 "url": "https://git.drupalcode.org/project/google_tag.git",
-                "reference": "8.x-1.1"
+                "reference": "8.x-1.3"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://ftp.drupal.org/files/projects/google_tag-8.x-1.1.zip",
-                "reference": "8.x-1.1",
-                "shasum": "69c434d465ccf7c180c39c3bfba7e1ae34aaaad7"
+                "url": "https://ftp.drupal.org/files/projects/google_tag-8.x-1.3.zip",
+                "reference": "8.x-1.3",
+                "shasum": "2619d19c9a6b1d8f7a2d0c2715d009cf3b11d697"
             },
             "require": {
                 "drupal/core": "~8.0"
@@ -5133,8 +5133,8 @@
                     "dev-1.x": "1.x-dev"
                 },
                 "drupal": {
-                    "version": "8.x-1.1",
-                    "datestamp": "1534988884",
+                    "version": "8.x-1.3",
+                    "datestamp": "1575649087",
                     "security-coverage": {
                         "status": "covered",
                         "message": "Covered by Drupal's security advisory policy"
diff --git a/vendor/composer/installed.json b/vendor/composer/installed.json
index 9747907e8a0697277765c18ba5dc583aafad6f91..4cc57ba20f7437716889a6eba27405c56379bb16 100644
--- a/vendor/composer/installed.json
+++ b/vendor/composer/installed.json
@@ -5267,18 +5267,18 @@
     },
     {
         "name": "drupal/google_tag",
-        "version": "1.1.0",
-        "version_normalized": "1.1.0.0",
+        "version": "1.3.0",
+        "version_normalized": "1.3.0.0",
         "source": {
             "type": "git",
             "url": "https://git.drupalcode.org/project/google_tag.git",
-            "reference": "8.x-1.1"
+            "reference": "8.x-1.3"
         },
         "dist": {
             "type": "zip",
-            "url": "https://ftp.drupal.org/files/projects/google_tag-8.x-1.1.zip",
-            "reference": "8.x-1.1",
-            "shasum": "69c434d465ccf7c180c39c3bfba7e1ae34aaaad7"
+            "url": "https://ftp.drupal.org/files/projects/google_tag-8.x-1.3.zip",
+            "reference": "8.x-1.3",
+            "shasum": "2619d19c9a6b1d8f7a2d0c2715d009cf3b11d697"
         },
         "require": {
             "drupal/core": "~8.0"
@@ -5289,8 +5289,8 @@
                 "dev-1.x": "1.x-dev"
             },
             "drupal": {
-                "version": "8.x-1.1",
-                "datestamp": "1534988884",
+                "version": "8.x-1.3",
+                "datestamp": "1575649087",
                 "security-coverage": {
                     "status": "covered",
                     "message": "Covered by Drupal's security advisory policy"
diff --git a/web/modules/google_tag/README.md b/web/modules/google_tag/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..ebea4f7c8fb07527d158a801aa2b91602f6568c5
--- /dev/null
+++ b/web/modules/google_tag/README.md
@@ -0,0 +1,102 @@
+## CONTENTS OF THIS FILE
+
+ * Introduction
+ * Requirements
+ * Installation
+ * Configuration
+ * Troubleshooting
+ * Maintainers
+
+## INTRODUCTION
+
+This project integrates the site with the Google Tag Manager (GTM) application.
+GTM allows you to deploy analytics and measurement tag configurations from a
+web-based user interface (hosted by Google) instead of requiring administrative
+access to your website.
+
+ * For a full description, visit the project page:  
+   https://www.drupal.org/project/google_tag
+
+ * To submit bug reports and feature suggestions, or to track changes:  
+   https://www.drupal.org/project/issues/google_tag
+
+## REQUIREMENTS
+
+Sign up for GTM and obtain a 'container ID' for your website. Enter the  
+'container ID' on the settings form for this module (see Configuration).
+
+ * https://tagmanager.google.com/
+
+## INSTALLATION
+
+Place the project files in an appropriate modules directory and enable the
+module as you would any other contributed module. For further information see:
+
+ * https://www.drupal.org/node/895232
+
+## CONFIGURATION
+
+Users in roles with the 'Administer Google Tag Manager' permission will be able
+to manage the module settings and containers for the site. Configure permissions
+as usual at:
+
+ * Administration » People » Permissions  
+ * admin/people/permissions
+
+From the module settings page, configure the snippet URI and the default
+conditions on which the tags are inserted on a page response. Conditions exist
+for: page paths, user roles, and response statuses. See:
+
+ * Administration » Configuration » System » Google Tag Manager  
+ * admin/config/system/google_tag/settings
+
+From the container management page, manage the containers to be inserted on a
+page response. Add one or more containers with separate container IDs and the
+snippet insertion conditions. See:
+
+ * Administration » Configuration » System » Google Tag Manager  
+ * admin/config/system/google_tag
+
+For development purposes, create a GTM environment for your website and enter
+the 'environment ID' on the 'Advanced' tab of the settings form.
+
+ * https://tagmanager.google.com/#/admin
+
+## TROUBLESHOOTING
+
+If the JavaScript snippets are not present in the HTML output, try the following
+steps to debug the situation:
+
+ * Confirm the snippet files exist at the snippet base URI shown on the module
+   settings page. By default this is public://google_tag/ which on most sites
+   equates to sites/default/files/google_tag/.
+
+   If missing or stale, then invoke a cache rebuild (see note below) or visit
+   the container management page, edit each container, and submit the form to
+   recreate the snippet files for the container.
+
+   The need to do this may arise if the project is deployed from one environment
+   to another (e.g. development to production) but the snippet files are not
+   deployed.
+
+   NOTE: Snippet files will only be recreated on cache rebuild if the 'Recreate
+   snippets on cache rebuild' setting is enabled (this is the default). A cache
+   rebuild can be triggered from the command line using drush or from the site
+   performance administration page.
+
+ * Enable debug output on the module settings page to display the result of each
+   snippet insertion condition in the message area. Modify the insertion
+   conditions as needed.
+
+If you retain the default module setting to 'Include the snippet as a file',
+then the Google Search Console will report that the site is NOT setup to use the
+Tag Manager. This report is a FALSE POSITIVE as the bot only checks for inline
+code on the script tag. It does not load the snippet file and inspect the code
+therein. Instead of relying on this bot, check whether the GTM snippets are
+loaded as a result of the snippet added by this project.
+
+## MAINTAINERS
+
+Current maintainer:
+
+ * Jim Berry (https://www.drupal.org/u/solotandem)
diff --git a/web/modules/google_tag/README.txt b/web/modules/google_tag/README.txt
deleted file mode 100644
index bd5c359f6f782a47187286d86719b88b02493ce7..0000000000000000000000000000000000000000
--- a/web/modules/google_tag/README.txt
+++ /dev/null
@@ -1,91 +0,0 @@
-
-CONTENTS OF THIS FILE
----------------------
-
- * Introduction
- * Requirements
- * Installation
- * Configuration
- * Troubleshooting
- * Maintainers
-
-
-INTRODUCTION
-------------
-
-This Google Tag Manager project allows non-technical stakeholders to manage the
-analytics for their website by triggering the insertion of tags and tracking
-systems onto their page(s) via Google's Tag Manager (GTM) hosted application.
-
- * For a full description, visit the project page:
-   https://www.drupal.org/project/google_tag
-
- * To submit bug reports and feature suggestions, or to track changes:
-   https://www.drupal.org/project/issues/google_tag
-
-
-REQUIREMENTS
-------------
-
-Sign up for GTM and obtain a 'container ID' for your website. Enter the
-'container ID' on the settings form for this module (see Configuration).
-
- * https://www.google.com/analytics/tag-manager/
-
-
-INSTALLATION
-------------
-
-Place the project files in an appropriate modules directory and enable the
-module as you would any other contributed module. For further information see:
-
- * https://www.drupal.org/node/895232
-
-
-CONFIGURATION
--------------
-
-Users in roles with the 'Administer Google Tag Manager' permission will be able
-to manage the settings for this module. Configure permissions as usual at:
-
- * Administration » People » Permissions
- * admin/people/permissions
-
-From the module settings page, configure the conditions on which the tags are
-inserted on a page response. Conditions exist for: page paths, user roles, and
-response statuses. See:
-
- * Administration » Configuration » System » Google Tag Manager
- * admin/config/system/google_tag
-
-For development purposes, create a GTM environment for your website and enter
-the 'environment ID' on the 'Advanced' tab of the settings form.
-
- * https://tagmanager.google.com/#/admin
-
-
-TROUBLESHOOTING
----------------
-
-If the JavaScript snippets are not present in the HTML output, try the following
-steps to debug the situation:
-
- * Confirm the snippet files exist at public://google_tag/ (on most sites this
-   equates to sites/default/files/google_tag/).
-
-   If missing, then visit the module settings page and submit the form to
-   recreate the snippet files. The need to do this may arise if the project is
-   deployed from one environment to another (e.g. development to production) but
-   the snippet files are not deployed.
-
- * Enable debug output on the 'Advanced' tab of the settings page to display the
-   result of each snippet insertion condition in the message area. Modify the
-   insertion conditions as needed.
-
-
-MAINTAINERS
------------
-
-Current maintainer:
-
- * Jim Berry (https://www.drupal.org/u/solotandem)
diff --git a/web/modules/google_tag/config/install/google_tag.settings.yml b/web/modules/google_tag/config/install/google_tag.settings.yml
index 0cc57d2c0e51624f5b020b49c0d6749e18ddce8e..cc20edfcf98690e833cf886e6de62629ea7aa16f 100644
--- a/web/modules/google_tag/config/install/google_tag.settings.yml
+++ b/web/modules/google_tag/config/install/google_tag.settings.yml
@@ -1,19 +1,21 @@
-container_id: ''
-path_toggle: 'exclude listed'
-path_list: "/admin*\n/batch*\n/node/add*\n/node/*/edit\n/node/*/delete\n/user/*/edit*\n/user/*/cancel*"
-role_toggle: 'exclude listed'
-role_list:
-  -
-status_toggle: 'exclude listed'
-status_list: "403\n404"
+uri: 'public:/'
 compact_snippet: true
 include_file: true
 rebuild_snippets: true
+flush_snippets: false
 debug_output: false
-data_layer: 'dataLayer'
-include_classes: false
-whitelist_classes: "google\nnonGooglePixels\nnonGoogleScripts\nnonGoogleIframes"
-blacklist_classes: "customScripts\ncustomPixels"
-include_environment: false
-environment_id: ''
-environment_token: ''
+_default_container:
+  container_id: ''
+  path_toggle: 'exclude listed'
+  path_list: "/admin*\n/batch*\n/node/add*\n/node/*/edit\n/node/*/delete\n/user/*/edit*\n/user/*/cancel*"
+  role_toggle: 'exclude listed'
+  role_list: {}
+  status_toggle: 'exclude listed'
+  status_list: "403\n404"
+  data_layer: 'dataLayer'
+  include_classes: false
+  whitelist_classes: "google\nnonGooglePixels\nnonGoogleScripts\nnonGoogleIframes"
+  blacklist_classes: "customScripts\ncustomPixels"
+  include_environment: false
+  environment_id: ''
+  environment_token: ''
diff --git a/web/modules/google_tag/config/schema/google_tag.data_types.schema.yml b/web/modules/google_tag/config/schema/google_tag.data_types.schema.yml
new file mode 100644
index 0000000000000000000000000000000000000000..69a05bf19b9bb0ee183429b084f7b56fdd7ed4c1
--- /dev/null
+++ b/web/modules/google_tag/config/schema/google_tag.data_types.schema.yml
@@ -0,0 +1,99 @@
+_google_tag_container:
+  type: mapping
+  label: 'Container settings'
+  mapping:
+    container_id:
+      type: string
+      label: 'Container ID'
+      #translatable: true
+    data_layer:
+      type: string
+      label: 'Data layer'
+      #translatable: true
+    include_classes:
+      type: boolean
+      label: 'Add classes to the data layer'
+    whitelist_classes:
+      type: string
+      label: 'White-listed classes'
+      #translatable: true
+    blacklist_classes:
+      type: string
+      label: 'Black-listed classes'
+      #translatable: true
+    include_environment:
+      type: boolean
+      label: 'Include an environment'
+    environment_id:
+      type: string
+      label: 'Environment ID'
+      #translatable: true
+    environment_token:
+      type: string
+      label: 'Environment token'
+      #translatable: true
+    path_toggle:
+      type: string
+      label: 'Add snippet on specific paths'
+      #translatable: true
+    path_list:
+      type: string
+      label: 'Listed paths'
+      #translatable: true
+    role_toggle:
+      type: string
+      label: 'Add snippet for specific roles'
+      #translatable: true
+    role_list:
+      type: sequence
+      label: 'Selected roles'
+      sequence:
+        type: string
+        label: 'Role'
+      #translatable: true
+    status_toggle:
+      type: string
+      label: 'Add snippet for specific statuses'
+      #translatable: true
+    status_list:
+      type: string
+      label: 'Listed statuses'
+      #translatable: true
+
+condition.gtag:
+  type: mapping
+  label: 'Condition'
+  mapping:
+    id:
+      type: string
+      label: 'ID'
+    #negate:
+      #type: boolean
+      #label: 'Negate'
+    uuid:
+      type: uuid
+    context_mapping:
+      type: sequence
+      label: 'Context assignments'
+      sequence:
+        type: string
+
+condition.plugin.gtag_domain:
+  type: condition.gtag
+  mapping:
+    domain_toggle:
+      type: string
+    domain_list:
+      type: sequence
+      sequence:
+        type: string
+
+condition.plugin.gtag_language:
+  type: condition.gtag
+  mapping:
+    language_toggle:
+      type: string
+    language_list:
+      type: sequence
+      sequence:
+        type: string
diff --git a/web/modules/google_tag/config/schema/google_tag.schema.yml b/web/modules/google_tag/config/schema/google_tag.schema.yml
index 6ad51032eab8da10e9bbbde99ca26b30dd8916d4..b8c9f0fc7a0a0247bcaeb4edefe494928bf8ff27 100644
--- a/web/modules/google_tag/config/schema/google_tag.schema.yml
+++ b/web/modules/google_tag/config/schema/google_tag.schema.yml
@@ -1,30 +1,10 @@
 google_tag.settings:
   type: config_object
+  label: 'Module settings and default container settings'
   mapping:
-    container_id:
-      type: string
-      label: 'Container ID'
-    path_toggle:
-      type: string
-      label: 'Add snippet on specific paths'
-    path_list:
+    uri:
       type: string
-      label: 'Listed paths'
-    role_toggle:
-      type: string
-      label: 'Add snippet for specific roles'
-    role_list:
-      type: sequence
-      label: 'Selected roles'
-      sequence:
-        type: string
-        label: 'Role'
-    status_toggle:
-      type: string
-      label: 'Add snippet for specific statuses'
-    status_list:
-      type: string
-      label: 'Listed statuses'
+      label: 'Snippet base URI'
     compact_snippet:
       type: boolean
       label: 'Compact the JavaScript snippet'
@@ -34,28 +14,90 @@ google_tag.settings:
     rebuild_snippets:
       type: boolean
       label: 'Recreate snippets on cache rebuild'
+    flush_snippets:
+      type: boolean
+      label: 'Recreate snippet directory on cache rebuild'
     debug_output:
       type: boolean
       label: 'Show debug output'
+    _default_container:
+      type: _google_tag_container
+
+google_tag.container.*:
+  type: config_entity
+  label: 'Container settings'
+  mapping:
+    id:
+      type: string
+      label: 'ID'
+    label:
+      type: label
+      label: 'Label'
+    weight:
+      type: integer
+      label: 'Weight'
+    #_container:
+      #type: _google_tag_container
+    container_id:
+      type: string
+      label: 'Container ID'
+      #translatable: true
     data_layer:
       type: string
       label: 'Data layer'
+      #translatable: true
     include_classes:
       type: boolean
       label: 'Add classes to the data layer'
     whitelist_classes:
       type: string
       label: 'White-listed classes'
+      #translatable: true
     blacklist_classes:
       type: string
       label: 'Black-listed classes'
+      #translatable: true
     include_environment:
       type: boolean
       label: 'Include an environment'
     environment_id:
       type: string
       label: 'Environment ID'
+      #translatable: true
     environment_token:
       type: string
       label: 'Environment token'
-
+      #translatable: true
+    path_toggle:
+      type: string
+      label: 'Add snippet on specific paths'
+      #translatable: true
+    path_list:
+      type: string
+      label: 'Listed paths'
+      #translatable: true
+    role_toggle:
+      type: string
+      label: 'Add snippet for specific roles'
+      #translatable: true
+    role_list:
+      type: sequence
+      label: 'Selected roles'
+      sequence:
+        type: string
+        label: 'Role'
+      #translatable: true
+    status_toggle:
+      type: string
+      label: 'Add snippet for specific statuses'
+      #translatable: true
+    status_list:
+      type: string
+      label: 'Listed statuses'
+      #translatable: true
+    conditions:
+      type: sequence
+      label: 'Insertion conditions'
+      sequence:
+        type: condition.plugin.[id]
+        label: 'Insertion condition'
diff --git a/web/modules/google_tag/google_tag.api.php b/web/modules/google_tag/google_tag.api.php
index 9f8f16d47ab02ef99a60b8383f395936e6c4eef1..930b84014f562bd4f221ee17994b58ea2a0b49c5 100644
--- a/web/modules/google_tag/google_tag.api.php
+++ b/web/modules/google_tag/google_tag.api.php
@@ -2,11 +2,13 @@
 
 /**
  * @file
- * Documents hooks provided by this module.
+ * Hooks provided by this module.
  *
  * @author Jim Berry ("solotandem", http://drupal.org/user/240748)
  */
 
+use Drupal\google_tag\Entity\Container;
+
 /**
  * @addtogroup hooks
  * @{
@@ -21,8 +23,10 @@
  *
  * @param bool $satisfied
  *   The snippet insertion state.
+ * @param \Drupal\google_tag\Entity\Container $container
+ *   The associated container object.
  */
-function hook_google_tag_insert_alter(&$satisfied) {
+function hook_google_tag_insert_alter(&$satisfied, Container $container) {
   // Do something to the state.
   $state = !$state;
 }
@@ -36,8 +40,10 @@ function hook_google_tag_insert_alter(&$satisfied) {
  * @param array $snippets
  *   Associative array of snippets keyed by type: script, noscript and
  *   data_layer.
+ * @param \Drupal\google_tag\Entity\Container $container
+ *   The associated container object.
  */
-function hook_google_tag_snippets_alter(&$snippets) {
+function hook_google_tag_snippets_alter(array &$snippets, Container $container) {
   // Do something to the script snippet.
   $snippets['script'] = str_replace('insertBefore', 'insertAfter', $snippets['script']);
 }
diff --git a/web/modules/google_tag/google_tag.info.yml b/web/modules/google_tag/google_tag.info.yml
index fd8033df49be973d32fb749f12e17d2e60eadd08..b7d194a4835aaa0acb2af7a8e3681e938fb2e5aa 100644
--- a/web/modules/google_tag/google_tag.info.yml
+++ b/web/modules/google_tag/google_tag.info.yml
@@ -2,11 +2,10 @@ name: 'Google Tag Manager'
 type: module
 description: 'Allows your website analytics to be managed using Google Tag Manager.'
 package: 'Statistics'
-# core: 8.x
+core: 8.x
 configure: google_tag.settings_form
 
-# Information added by Drupal.org packaging script on 2018-08-23
-version: '8.x-1.1'
-core: '8.x'
+# Information added by Drupal.org packaging script on 2019-12-06
+version: '8.x-1.3'
 project: 'google_tag'
-datestamp: 1534988886
+datestamp: 1575649137
diff --git a/web/modules/google_tag/google_tag.install b/web/modules/google_tag/google_tag.install
index 8c9ff1a532526eee96d8ac86b650972c22149ff1..1a3be9b0191d1892de07c9845663826e168f0f5f 100644
--- a/web/modules/google_tag/google_tag.install
+++ b/web/modules/google_tag/google_tag.install
@@ -7,29 +7,45 @@
  * @author Jim Berry ("solotandem", http://drupal.org/user/240748)
  */
 
+use Drupal\Core\File\FileSystemInterface;
+use Drupal\Core\Url;
+
 /**
  * Implements hook_requirements().
  */
 function google_tag_requirements($phase) {
   $requirements = array();
   if ($phase == 'runtime') {
-    $config = \Drupal::config('google_tag.settings');
-    if (!preg_match('/^GTM-\w{4,}$/', $config->get('container_id'))) {
+    $containers = \Drupal::service('entity_type.manager')->getStorage('google_tag_container')->loadMultiple();
+    if (empty($containers)) {
       // Google Tag Manager container ID has not been set.
       $requirements['google_tag'] = array(
         'title' => t('Google Tag Manager'),
-        'description' => t('Configure this integration module on its <a href=":url">settings page</a>.', array(':url' => \Drupal::url('google_tag.settings_form'))),
+        'description' => t('Configure default settings on the <a href=":url1">module settings page</a>. Afterwards, add a container on the <a href=":url2">container management page</a>.', array(':url1' => Url::fromRoute('google_tag.settings_form')->toString(), ':url2' => Url::fromRoute('entity.google_tag_container.collection')->toString())),
         'severity' => REQUIREMENT_WARNING,
         'value' => t('Not configured'),
       );
     }
   }
   if ($phase == 'runtime' || $phase == 'update' || $phase == 'install') {
+    $phase == 'install' ? require_once __DIR__ . '/google_tag.module' : '';
     // Adapted from system_requirements().
-    $directory = 'public://google_tag';
-    $phase == 'install' ? module_load_include('module', 'google_tag') : '';
+    $directory = \Drupal::config('google_tag.settings')->get('uri');
+    if (empty($directory)) {
+      if ($phase == 'runtime' || $phase == 'update') {
+        $requirements['google_tag_snippet_parent_directory'] = array(
+          'title' => t('Google Tag Manager'),
+          'description' => t('The snippet parent directory is not set. Configure default settings on the <a href=":url1">module settings page</a>.', array(':url1' => Url::fromRoute('google_tag.settings_form')->toString())),
+          'severity' => REQUIREMENT_ERROR,
+          'value' => t('Not configured'),
+        );
+        return $requirements;
+      }
+      $directory = 'public:/';
+    }
+    $directory .= '/google_tag';
     if (!is_dir($directory) || !_google_tag_is_writable($directory) || !_google_tag_is_executable($directory)) {
-      __file_prepare_directory($directory, FILE_CREATE_DIRECTORY | FILE_MODIFY_PERMISSIONS);
+      _file_prepare_directory($directory, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS);
     }
     $is_executable = _google_tag_is_executable($directory);
     $is_writable = _google_tag_is_writable($directory);
@@ -93,8 +109,8 @@ function google_tag_requirements($phase) {
  * Implements hook_install().
  */
 function google_tag_install() {
-  global $google_tag_display_message;
-  $google_tag_display_message = TRUE;
+  global $_google_tag_display_message;
+  $_google_tag_display_message = TRUE;
   _google_tag_assets_create();
 }
 
@@ -102,7 +118,91 @@ function google_tag_install() {
  * Implements hook_uninstall().
  */
 function google_tag_uninstall() {
-  @file_unmanaged_delete_recursive('public://google_tag');
-  \Drupal::service('asset.js.collection_optimizer')->deleteAll();
+  if (\Drupal::config('google_tag.settings')->get('flush_snippets')) {
+    $directory = \Drupal::config('google_tag.settings')->get('uri');
+    if (!empty($directory)) {
+      // Remove snippet file directory.
+      \Drupal::service('file_system')->deleteRecursive($directory . '/google_tag');
+    }
+  }
+
+  // Reset the URL query argument so browsers reload snippet files.
   _drupal_flush_css_js();
 }
+
+/**
+ * Convert config item to separate module settings and container config items.
+ */
+function google_tag_update_8101(&$sandbox) {
+  $data = \Drupal::config('google_tag.settings')->get();
+  if (!empty($data['_default_container'])) {
+    // Config appears to be updated; do nothing.
+    return t('Config appears to be updated; no changes made');
+  }
+
+  // Create a container configuration item.
+  $container_config = \Drupal::service('config.factory')->getEditable('google_tag.container.primary');
+  if (!empty($container_config->get())) {
+    // Config appears to be updated; do nothing.
+    return t('Config appears to be updated; no changes made');
+  }
+
+  $keys = array_flip(['uri', 'compact_snippet', 'include_file', 'rebuild_snippets', 'debug_output', '_core']);
+  $data = array_diff_key($data, $keys);
+  $container_data = ['status' => TRUE, 'id' => 'primary', 'label' => 'Primary', 'weight' => 0] + $data;
+  $container_config->setData($container_data)->save();
+
+  // Update the module configuration item.
+  $module_config = \Drupal::service('config.factory')->getEditable('google_tag.settings');
+  $module_data = $module_config->get();
+  unset($keys['_core']);
+  $data['container_id'] = '';
+  $module_data = array_intersect_key($module_data, $keys);
+  $module_data = ['uri' => 'public://google_tag'] + $module_data + ['_default_container' => $data];
+  $module_config->setData($module_data)->save();
+
+  return t('Converted config item to separate settings and container config items');
+}
+
+/**
+ * Install the container configuration entity type.
+ */
+function google_tag_update_8102(&$sandbox) {
+  $type_manager = \Drupal::entityTypeManager();
+  $type_manager->clearCachedDefinitions();
+  $entity_type = $type_manager->getDefinition('google_tag_container');
+  \Drupal::entityDefinitionUpdateManager()->installEntityType($entity_type);
+
+  return t('Installed the google_tag_container entity type');
+}
+
+/**
+ * Update the snippet parent URI and add the flush_snippets setting.
+ */
+function google_tag_update_8103(&$sandbox) {
+  $module_config = \Drupal::service('config.factory')->getEditable('google_tag.settings');
+  $module_data = $module_config->get();
+
+  // Update the module settings.
+  $snippet_uri_changed = TRUE;
+  $uri = isset($module_data['uri']) ? $module_data['uri'] : 'public:/';
+  if (substr($uri, -11) == '/google_tag') {
+    // Remove the default directory as this will be appended in code.
+    $uri = substr($uri, 0, -11);
+    $snippet_uri_changed = FALSE;
+  }
+  if (substr($uri, -3) == '://') {
+    // Remove the last slash from a bare stream wrapper.
+    $uri = substr($uri, 0, -1);
+  }
+
+  $module_data = ['uri' => $uri, 'flush_snippets' => FALSE] + $module_data;
+  $keys = array_flip(['uri', 'compact_snippet', 'include_file', 'rebuild_snippets', 'flush_snippets', 'debug_output']);
+  $module_data = array_merge($keys, $module_data);
+  $module_config->setData($module_data)->save();
+
+  if ($snippet_uri_changed) {
+    return t('Updated the snippet parent URI and added the flush_snippets setting. The old snippet directory was not deleted.');
+  }
+  return t('Added the flush_snippets setting and retained the snippet parent URI.');
+}
diff --git a/web/modules/google_tag/google_tag.links.action.yml b/web/modules/google_tag/google_tag.links.action.yml
new file mode 100644
index 0000000000000000000000000000000000000000..22f88a5e9a760d3a035c13f75905b0dd9d67940f
--- /dev/null
+++ b/web/modules/google_tag/google_tag.links.action.yml
@@ -0,0 +1,5 @@
+entity.google_tag_container.add_form:
+  route_name: entity.google_tag_container.add_form
+  title: 'Add container'
+  appears_on:
+    - entity.google_tag_container.collection
diff --git a/web/modules/google_tag/google_tag.links.menu.yml b/web/modules/google_tag/google_tag.links.menu.yml
index c394f1b841a004b5cc752ade9f9a229f4b0183c6..da8170aa72712102f6354a9ecb594aeb377f7868 100644
--- a/web/modules/google_tag/google_tag.links.menu.yml
+++ b/web/modules/google_tag/google_tag.links.menu.yml
@@ -1,5 +1,5 @@
-google_tag.settings_form:
+entity.google_tag_container.collection:
   title: 'Google Tag Manager'
   description: 'Configure the website integration with GTM and the resultant capturing of website analytics.'
-  route_name: google_tag.settings_form
+  route_name: entity.google_tag_container.collection
   parent: system.admin_config_system
diff --git a/web/modules/google_tag/google_tag.links.task.yml b/web/modules/google_tag/google_tag.links.task.yml
new file mode 100644
index 0000000000000000000000000000000000000000..90c079ff850f243b6569b9a9e3a5385daa17520e
--- /dev/null
+++ b/web/modules/google_tag/google_tag.links.task.yml
@@ -0,0 +1,9 @@
+google_tag.container_list_tab:
+  route_name: entity.google_tag_container.collection
+  title: Containers
+  base_route: entity.google_tag_container.collection
+
+google_tag.settings_form_tab:
+  route_name: google_tag.settings_form
+  title: Settings
+  base_route: entity.google_tag_container.collection
diff --git a/web/modules/google_tag/google_tag.module b/web/modules/google_tag/google_tag.module
index 9175c09ae8bc909ef5005cb90ae86b18be957752..9e3db9714c60538f442f2ca1e168e23ba82dbd94 100644
--- a/web/modules/google_tag/google_tag.module
+++ b/web/modules/google_tag/google_tag.module
@@ -10,7 +10,7 @@
  * @author Jim Berry ("solotandem", http://drupal.org/user/240748)
  */
 
-use Drupal\Component\Utility\Unicode;
+use Drupal\Core\File\FileSystemInterface;
 use Drupal\Core\Routing\RouteMatchInterface;
 
 /**
@@ -48,256 +48,53 @@ function google_tag_rebuild() {
  * Saves snippet files and data layer classes based on current settings.
  */
 function _google_tag_assets_create() {
-  $config_factory = \Drupal::configFactory();
-  $form = new Drupal\google_tag\Form\GoogleTagSettingsForm($config_factory);
-  $form->createAssets();
+  $manager = \Drupal::service('google_tag.container_manager');
+  $manager->createAllAssets();
 }
 
 /**
  * Implements hook_page_attachments().
  */
-function google_tag_page_attachments(&$attachments) {
-  if (!google_tag_insert_snippet()) {
-    return;
-  }
-
-  $config = \Drupal::config('google_tag.settings');
-  $include_script_as_file = $config->get('include_file');
-  $include_classes = $config->get('include_classes');
-  $types = $include_classes ? ['data_layer', 'script'] : ['script'];
-
-  // Add data_layer and script snippets to head (no longer by default).
-  $weight = 9;
-  if ($include_script_as_file) {
-    foreach ($types as $type) {
-      // @todo Will it matter if file is empty?
-      // @todo Check config for the whitelist and blacklist classes before adding.
-      $attachments['#attached']['html_head'][] = _google_tag_file_tag($type, $weight++);
-    }
-  }
-  else {
-    foreach ($types as $type) {
-      // @see drupal_get_js() in 7.x core.
-      // For inline JavaScript to validate as XHTML, all JavaScript containing
-      // XHTML needs to be wrapped in CDATA.
-      $attachments['#attached']['html_head'][] = _google_tag_inline_tag($type, $weight++);
-    }
-  }
-}
-
-/**
- * Returns tag array for the snippet type.
- *
- * @param string $type
- *   The snippet type.
- * @param int $weight
- *   The weight of the item.
- *
- * @return array
- *   The tag array.
- */
-function _google_tag_file_tag($type, $weight) {
-  $uri = "public://google_tag/google_tag.$type.js";
-  $url = file_url_transform_relative(file_create_url($uri));
-  $query_string = \Drupal::state()->get('system.css_js_query_string') ?: '0';
-  $attachment = [
-    [
-      '#type' => 'html_tag',
-      '#tag' => 'script',
-      '#attributes' => ['src' => $url . '?' . $query_string],
-      '#weight' => $weight,
-    ],
-    "google_tag_{$type}_tag",
-  ];
-  return $attachment;
-}
-
-/**
- * Returns tag array for the snippet type.
- *
- * @param string $type
- *   The snippet type.
- * @param int $weight
- *   The weight of the item.
- *
- * @return array
- *   The tag array.
- */
-function _google_tag_inline_tag($type, $weight) {
-  $uri = "public://google_tag/google_tag.$type.js";
-  $url = \Drupal::service('file_system')->realpath($uri);
-  $contents = @file_get_contents($url);
-  $attachment = $contents ? [
-    [
-      '#type' => 'html_tag',
-      '#tag' => 'script',
-      '#value' => $contents,
-      '#weight' => $weight,
-    ],
-    "google_tag_{$type}_tag",
-  ] : [];
-  return $attachment;
+function google_tag_page_attachments(array &$attachments) {
+  $manager = \Drupal::service('google_tag.container_manager');
+  $manager->getScriptAttachments($attachments);
 }
 
 /**
  * Implements hook_page_top().
  */
-function google_tag_page_top(&$page) {
-  if (!google_tag_insert_snippet()) {
-    return;
-  }
-
-  // Add noscript snippet to page_top region.
-  $uri = 'public://google_tag/google_tag.noscript.js';
-  $url = \Drupal::service('file_system')->realpath($uri);
-  $contents = @file_get_contents($url);
-
-  // Note: depending on the theme, this may not place the snippet immediately
-  // after the body tag but should be close and it can be altered.
-
-  // @see https://api.drupal.org/api/drupal/core!lib!Drupal!Core!Render!theme.api.php/group/theme_render/8.2.x
-  // The markup is passed through \Drupal\Component\Utility\Xss::filterAdmin()
-  // which strips known vectors while allowing a permissive list of HTML tags
-  // that are not XSS vectors. (e.g., <script> and <style> are not allowed.)
-  // @todo Core is removing the 'style' attribute from the noscript snippet.
-  if ($contents) {
-    $page['google_tag_noscript_tag'] = [
-      '#markup' => $contents,
-      '#allowed_tags' => ['noscript', 'iframe'],
-      '#weight' => -10,
-    ];
-  }
+function google_tag_page_top(array &$page) {
+  $manager = \Drupal::service('google_tag.container_manager');
+  $manager->getNoScriptAttachments($page);
 }
 
 /**
- * Determines whether to insert the snippet on the response.
+ * Implements hook_plugin_filter_TYPE_alter().
  *
- * @return bool
- *   TRUE if the conditions are met; FALSE otherwise.
+ * @see ContainerForm::conditionsForm()
  */
-function google_tag_insert_snippet() {
-  static $satisfied;
-
-  if (!isset($satisfied)) {
-    $config = \Drupal::config('google_tag.settings');
-    $debug = $config->get('debug_output');
-    $id = $config->get('container_id');
+function google_tag_plugin_filter_condition_alter(array &$definitions, array $extra, $consumer) {
+  if ($consumer == 'google_tag') {
+    // Remove condition plugins defined by core and domain.
+    $definitions = array_diff_key($definitions, array_flip([
+      'current_theme', 'language', 'node_type', 'request_path', 'user_role',
+      'domain',
+    ]));
 
-    if (empty($id)) {
-      // No container ID.
-      return FALSE;
-    }
-
-    $satisfied = TRUE;
-    if (!_google_tag_status_check() || !_google_tag_path_check() || !_google_tag_role_check()) {
-      // Omit snippet if any condition is not met.
-      $satisfied = FALSE;
+    $language_manager = \Drupal::service('language_manager');
+    if (!$language_manager->isMultilingual()) {
+      // Omit the language condition until multiple languages.
+      unset($definitions['gtag_language']);
     }
-
-    // Allow other modules to alter the insertion criteria.
-    \Drupal::moduleHandler()->alter('google_tag_insert', $satisfied);
-    $debug ? drupal_set_message(t('after alter @satisfied', ['@satisfied' => $satisfied])) : '';
   }
-  return $satisfied;
-}
-
-/**
- * Determines whether to insert the snippet based on status code settings.
- *
- * @return bool
- *   TRUE if the status conditions are met; FALSE otherwise.
- */
-function _google_tag_status_check() {
-  static $satisfied;
-
-  if (!isset($satisfied)) {
-    $config = \Drupal::config('google_tag.settings');
-    $debug = $config->get('debug_output');
-    $toggle = $config->get('status_toggle');
-    $statuses = $config->get('status_list');
-
-    if (empty($statuses)) {
-      $satisfied = ($toggle == GOOGLE_TAG_EXCLUDE_LISTED);
-    }
-    else {
-      // Get the HTTP response status.
-      $request = \Drupal::request();
-      $status = '200';
-      if ($exception = $request->attributes->get('exception')) {
-        $status = $exception->getStatusCode();
+  else {
+    foreach ($definitions as $id => $definition) {
+      if (substr($id, 0, 5) == 'gtag_') {
+        // Prevent use of custom plugins by other consumers.
+        unset($definitions[$id]);
       }
-      $satisfied = strpos($statuses, (string) $status) !== FALSE;
-      $satisfied = ($toggle == GOOGLE_TAG_EXCLUDE_LISTED) ? !$satisfied : $satisfied;
-    }
-    $debug ? drupal_set_message(t('google_tag')) : '';
-    $debug ? drupal_set_message(t('status check @satisfied', ['@satisfied' => $satisfied])) : '';
-  }
-  return $satisfied;
-}
-
-/**
- * Determines whether to insert the snippet based on the path settings.
- *
- * @return bool
- *   TRUE if the path conditions are met; FALSE otherwise.
- */
-function _google_tag_path_check() {
-  static $satisfied;
-
-  if (!isset($satisfied)) {
-    $config = \Drupal::config('google_tag.settings');
-    $debug = $config->get('debug_output');
-    $toggle = $config->get('path_toggle');
-    $paths = Unicode::strtolower($config->get('path_list'));
-
-    if (empty($paths)) {
-      $satisfied = ($toggle == GOOGLE_TAG_EXCLUDE_LISTED);
-    }
-    else {
-      $request = \Drupal::request();
-      $current_path = \Drupal::service('path.current');
-      $alias_manager = \Drupal::service('path.alias_manager');
-      $path_matcher = \Drupal::service('path.matcher');
-      // @todo Are not some paths case sensitive???
-      // Compare the lowercase path alias (if any) and internal path.
-      $path = $current_path->getPath($request);
-      $path_alias = Unicode::strtolower($alias_manager->getAliasByPath($path));
-      $satisfied = $path_matcher->matchPath($path_alias, $paths) || (($path != $path_alias) && $path_matcher->matchPath($path, $paths));
-      $satisfied = ($toggle == GOOGLE_TAG_EXCLUDE_LISTED) ? !$satisfied : $satisfied;
     }
-    $debug ? drupal_set_message(t('path check @satisfied', ['@satisfied' => $satisfied])) : '';
   }
-  return $satisfied;
-}
-
-/**
- * Determines whether to insert the snippet based on the user role settings.
- *
- * @return bool
- *   TRUE if the role conditions are met; FALSE otherwise.
- */
-function _google_tag_role_check() {
-  static $satisfied;
-
-  if (!isset($satisfied)) {
-    $config = \Drupal::config('google_tag.settings');
-    $debug = $config->get('debug_output');
-    $toggle = $config->get('role_toggle');
-    $roles = $config->get('role_list');
-    $roles = array_filter($roles);
-
-    if (empty($roles)) {
-      $satisfied = ($toggle == GOOGLE_TAG_EXCLUDE_LISTED);
-    }
-    else {
-      $satisfied = FALSE;
-      // Check user roles against listed roles.
-      $satisfied = (bool) array_intersect($roles, \Drupal::currentUser()->getRoles());
-      $satisfied = ($toggle == GOOGLE_TAG_EXCLUDE_LISTED) ? !$satisfied : $satisfied;
-    }
-    $debug ? drupal_set_message(t('role check @satisfied', ['@satisfied' => $satisfied])) : '';
-  }
-  return $satisfied;
 }
 
 /**
@@ -307,8 +104,9 @@ function _google_tag_role_check() {
  *
  * @see file_prepare_directory()
  */
-function __file_prepare_directory(&$directory, $options = FILE_MODIFY_PERMISSIONS) {
-  if (!file_stream_wrapper_valid_scheme(\Drupal::service('file_system')->uriScheme($directory))) {
+function _file_prepare_directory(&$directory, $options = FileSystemInterface::MODIFY_PERMISSIONS) {
+  $file_system = \Drupal::service('file_system');
+  if (!$file_system->validScheme($file_system->uriScheme($directory))) {
     // Only trim if we're not dealing with a stream.
     $directory = rtrim($directory, '/\\');
   }
@@ -317,15 +115,15 @@ function __file_prepare_directory(&$directory, $options = FILE_MODIFY_PERMISSION
   if (!is_dir($directory)) {
     // Let mkdir() recursively create directories and use the default directory
     // permissions.
-    if ($options & FILE_CREATE_DIRECTORY) {
-      return @drupal_mkdir($directory, NULL, TRUE);
+    if ($options & FileSystemInterface::CREATE_DIRECTORY) {
+      return @$file_system->mkdir($directory, NULL, TRUE);
     }
     return FALSE;
   }
   // The directory exists, so check to see if it is writable.
   $writable = _google_tag_is_writable($directory) && _google_tag_is_executable($directory);
-  if (!$writable && ($options & FILE_MODIFY_PERMISSIONS)) {
-    return drupal_chmod($directory);
+  if (!$writable && ($options & FileSystemInterface::MODIFY_PERMISSIONS)) {
+    return $file_system->chmod($directory);
   }
 
   return $writable;
@@ -367,7 +165,9 @@ function _google_tag_is_executable($uri) {
   if ($realpath = \Drupal::service('file_system')->realpath($uri)) {
     // The URI is a local stream wrapper or a local path.
     // Use the local path since PHP only checks ACLs on its local file wrapper.
-    return is_executable($realpath);
+    // Remove the OS check if PHP is_executable() is changed to not return FALSE
+    // simply because the URI points to a directory (not a file) on Windows.
+    return _google_tag_is_windows() || is_executable($realpath);
   }
   if ($wrapper = \Drupal::service('stream_wrapper_manager')->getViaUri($uri)) {
     // The URI is a remote stream wrapper.
@@ -375,6 +175,14 @@ function _google_tag_is_executable($uri) {
       return FALSE;
     }
 
+    if (!function_exists('posix_getuid') || !function_exists('posix_getgid')) {
+      // These functions are never defined on Windows and the extension that
+      // provides them may not be included on a Linux distribution.
+      // If directory is not searchable, then fault the site deployment process.
+      // @todo Is it worse to return true or false at this point?
+      return TRUE;
+    }
+
     // Determine the appropriate permissions bit mask as an octal.
     // The stat array is likely to have uid=gid=0 so that the mask is octal 01.
     // This is true for Amazon S3 and Google Cloud Storage.
@@ -389,3 +197,14 @@ function _google_tag_is_executable($uri) {
   }
   return FALSE;
 }
+
+/**
+ * Determines whether the operating system is Windows.
+ *
+ * @return bool
+ *   Whether the operating system is Windows.
+ */
+function _google_tag_is_windows() {
+  return (defined('PHP_OS_FAMILY') && PHP_OS_FAMILY == 'Windows') ||
+    (defined('PHP_OS') && strcasecmp(substr(PHP_OS, 0, 3), 'win') == 0);
+}
diff --git a/web/modules/google_tag/google_tag.routing.yml b/web/modules/google_tag/google_tag.routing.yml
index 9d2fe9888930e108b8d327d0e1c054cdc06f28ea..9c183956999d2a0943f96ef1fb9ca91db0150819 100644
--- a/web/modules/google_tag/google_tag.routing.yml
+++ b/web/modules/google_tag/google_tag.routing.yml
@@ -1,7 +1,82 @@
+# default settings
 google_tag.settings_form:
-  path: '/admin/config/system/google_tag'
+  path: '/admin/config/system/google-tag/settings'
   defaults:
-    _title: 'Google Tag Manager'
-    _form: '\Drupal\google_tag\Form\GoogleTagSettingsForm'
+    _title: 'Google Tag Manager settings'
+    _form: '\Drupal\google_tag\Form\SettingsForm'
+  requirements:
+    _permission: 'administer google tag manager'
+
+# container management
+entity.google_tag_container.collection:
+  path: '/admin/config/system/google-tag'
+  defaults:
+    #_title: 'Google Tag Manager'
+    #_form: '\Drupal\google_tag\Form\ContainerListBuilder'
+    _entity_list: 'google_tag_container'
+    _title: 'Google Tag Manager containers'
+  requirements:
+    _permission: 'administer google tag manager'
+
+entity.google_tag_container.add_form:
+  path: '/admin/config/system/google-tag/add'
+  defaults:
+    #_title_arguments are only used with a _title..go figure
+    #see Drupal\Core\Controller\TitleResolver->getTitle()
+    #with a _title_callback
+    #Drupal\Core\Controller\ControllerResolver::doGetArguments()
+    #expects the argument to be top level in this defaults array
+    _entity_form: 'google_tag_container'
+    _title: 'Add Google Tag container'
+    #_title_arguments:
+      #entity_type_id: 'entity.google_tag_container'
+    _title_callback: '\Drupal\google_tag\ContainerController::addTitle'
+    entity_type_id: 'google_tag_container' # 'entity.google_tag_container argument'
+  requirements:
+    _permission: 'administer google tag manager'
+
+#the next two require the method parameter to be named google_tag_container
+entity.google_tag_container.enable:
+  path: '/admin/config/system/google-tag/manage/{google_tag_container}/enable'
+  defaults:
+    _controller: '\Drupal\google_tag\ContainerController::enable'
+    entity_type: 'google_tag_container'
+  requirements:
+    _permission: 'administer google tag manager'
+
+entity.google_tag_container.disable:
+  path: '/admin/config/system/google-tag/manage/{google_tag_container}/disable'
+  defaults:
+    _controller: '\Drupal\google_tag\ContainerController::disable'
+    entity_type: 'google_tag_container'
+  requirements:
+    _permission: 'administer google tag manager'
+
+#according to documentation this should work but does not
+#https://www.drupal.org/docs/8/api/routing-system/parameter-upcasting-in-routes
+#entity.google_tag_container.disable:
+  #path: '/admin/config/system/google-tag/manage/{container}/disable'
+  #defaults:
+    #_controller: '\Drupal\google_tag\ContainerController::disable'
+    #entity_type: 'google_tag_container'
+  #requirements:
+    #_permission: 'administer google tag manager'
+  #options:
+    #parameters:
+      #container:
+        #type: entity:google_tag_container
+
+entity.google_tag_container.edit_form:
+  path: '/admin/config/system/google-tag/manage/{google_tag_container}'
+  defaults:
+    _entity_form: google_tag_container
+    _title_callback: '\Drupal\google_tag\ContainerController::editTitle'
+  requirements:
+    _permission: 'administer google tag manager'
+
+entity.google_tag_container.delete_form:
+  path: '/admin/config/system/google-tag/manage/{google_tag_container}/delete'
+  defaults:
+    _entity_form: 'google_tag_container.delete'
   requirements:
     _permission: 'administer google tag manager'
diff --git a/web/modules/google_tag/google_tag.services.yml b/web/modules/google_tag/google_tag.services.yml
new file mode 100644
index 0000000000000000000000000000000000000000..b0b7d42701c9b0e195323b510a10d92807c512df
--- /dev/null
+++ b/web/modules/google_tag/google_tag.services.yml
@@ -0,0 +1,4 @@
+services:
+  google_tag.container_manager:
+    class: Drupal\google_tag\Entity\ContainerManager
+    arguments: ['@entity_type.manager', '@module_handler', '@file_system', '@messenger', '@logger.factory']
diff --git a/web/modules/google_tag/includes/snippet.inc b/web/modules/google_tag/includes/snippet.inc
deleted file mode 100644
index 1e9bc6191f143daef7ef0d6bcbaeedce0a522f2a..0000000000000000000000000000000000000000
--- a/web/modules/google_tag/includes/snippet.inc
+++ /dev/null
@@ -1,165 +0,0 @@
-<?php
-
-/**
- * @file
- * Contains the JavaScript snippet insertion code.
- *
- * @author Jim Berry ("solotandem", http://drupal.org/user/240748)
- */
-
-/**
- * Returns JavaScript snippets.
- *
- * @return array
- *   Associative array of snippets keyed by type: script, noscript and
- *   data_layer.
- */
-function google_tag_snippets() {
-  $snippets = [
-    'script' => _google_tag_script_snippet(),
-    'noscript' => _google_tag_noscript_snippet(),
-    'data_layer' => _google_tag_data_layer_snippet(),
-  ];
-  // Allow other modules to alter the snippets.
-  \Drupal::moduleHandler()->alter('google_tag_snippets', $snippets);
-  return $snippets;
-}
-
-/**
- * Returns JavaScript script snippet.
- *
- * @return array
- *   The script snippet.
- */
-function _google_tag_script_snippet() {
-  // Gather data.
-  $config = \Drupal::config('google_tag.settings');
-  $container_id = _google_tag_variable_clean('container_id');
-  $data_layer = _google_tag_variable_clean('data_layer');
-  $query = _google_tag_environment_query();
-  $compact = $config->get('compact_snippet');
-
-  // Build script snippet.
-  $script = <<<EOS
-(function(w,d,s,l,i){
-
-  w[l]=w[l]||[];
-  w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});
-  var f=d.getElementsByTagName(s)[0];
-  var j=d.createElement(s);
-  var dl=l!='dataLayer'?'&l='+l:'';
-  j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl+'$query';
-  j.async=true;
-  f.parentNode.insertBefore(j,f);
-
-})(window,document,'script','$data_layer','$container_id');
-EOS;
-  if ($compact) {
-    $script = str_replace(["\n", '  '], '', $script);
-  }
-/*
-  $script = <<<EOS
-<!-- Google Tag Manager -->
-$script
-<!-- End Google Tag Manager -->
-EOS;
-*/
-  return $script;
-}
-
-/**
- * Returns JavaScript noscript snippet.
- *
- * @return array
- *   The noscript snippet.
- */
-function _google_tag_noscript_snippet() {
-  // Gather data.
-  $config = \Drupal::config('google_tag.settings');
-  $container_id = _google_tag_variable_clean('container_id');
-  $query = _google_tag_environment_query();
-  $compact = $config->get('compact_snippet');
-
-  // Build noscript snippet.
-  // @todo Core removes the 'style' attribute from the snippet; so omit it.
-  // style="display:none;visibility:hidden"
-  $noscript = <<<EOS
-<noscript aria-hidden="true"><iframe src="https://www.googletagmanager.com/ns.html?id=$container_id$query"
- height="0" width="0" title="Google Tag Manager"></iframe></noscript>
-EOS;
-  if ($compact) {
-    $noscript = str_replace("\n", '', $noscript);
-  }
-/*
-  $noscript = <<<EOS
-<!-- Google Tag Manager -->
-$noscript
-<!-- End Google Tag Manager -->
-EOS;
-*/
-  return $noscript;
-}
-
-/**
- * Returns JavaScript data layer snippet or adds items to data layer.
- *
- * @return string|null
- *   The data layer snippet or NULL.
- */
-function _google_tag_data_layer_snippet() {
-  // Gather data.
-  $config = \Drupal::config('google_tag.settings');
-  $data_layer = _google_tag_variable_clean('data_layer');
-  $whitelist = $config->get('whitelist_classes');
-  $blacklist = $config->get('blacklist_classes');
-
-  $classes = [];
-  $names = ['whitelist', 'blacklist'];
-  foreach ($names as $name) {
-    $$name = explode("\n", $$name);
-    if (empty($$name)) {
-      continue;
-    }
-    $classes["gtm.$name"] = $$name;
-  }
-
-  if ($classes) {
-    // Build data layer snippet.
-    $script = "var $data_layer = [" . json_encode($classes) . '];';
-    return $script;
-  }
-}
-
-/**
- * Returns a query string with the environment parameters.
- *
- * @return string
- *   The query string.
- */
-function _google_tag_environment_query() {
-  $config = \Drupal::config('google_tag.settings');
-  if (!$config->get('include_environment')) {
-    return '';
-  }
-
-  // Gather data.
-  $environment_id = _google_tag_variable_clean('environment_id');
-  $environment_token = _google_tag_variable_clean('environment_token');
-
-  // Build query string.
-  return "&gtm_auth=$environment_token&gtm_preview=$environment_id&gtm_cookies_win=x";
-}
-
-/**
- * Returns a cleansed variable.
- *
- * @param string $variable
- *   The variable name.
- *
- * @return string
- *   The cleansed variable.
- */
-function _google_tag_variable_clean($variable) {
-  $config = \Drupal::config('google_tag.settings');
-  return trim(json_encode($config->get($variable)), '"');
-}
diff --git a/web/modules/google_tag/js/google_tag.admin.js b/web/modules/google_tag/js/google_tag.admin.js
index da9a14402d4e130b006eb4e535276495bb9953d7..d26f17c791585bca19b47675193ec75cf811ff76 100644
--- a/web/modules/google_tag/js/google_tag.admin.js
+++ b/web/modules/google_tag/js/google_tag.admin.js
@@ -1,8 +1,6 @@
 /**
  * @file
  * Behaviors and utility functions for administrative pages.
- *
- * @author Jim Berry ("solotandem", http://drupal.org/user/240748)
  */
 
 (function ($) {
@@ -13,71 +11,62 @@
   * Provides summary information for the vertical tabs.
   */
   Drupal.behaviors.gtmInsertionSettings = {
-    attach: function (context) {
+    attach: function (context, settings) {
 
-      $('details#edit-path', context).drupalSetSummary(function (context) {
-        var $radio = $('input[name="path_toggle"]:checked', context);
-        if ($radio.val() == 'exclude listed') {
-          if (!$('textarea[name="path_list"]', context).val()) {
-            return Drupal.t('All paths');
+      // Pass context parameters to outer function.
+      function toggleValuesSummary(element, plural, adjective) {
+        // Return a callback function as expected by drupalSetSummary().
+        return function (context) {
+          console.log("inside toggleValuesSummary");
+          console.log("plural=" + plural);
+          var str = '';
+          var toggle = $('input[type="radio"]:checked', context).val();
+          var values;
+          if (element == 'checkbox') {
+            values = $('input[type="checkbox"]:checked + label', context).length;
           }
           else {
-            return Drupal.t('All paths except listed paths');
+            var values = $('textarea', context).val();
           }
-        }
-        else {
-          if (!$('textarea[name="path_list"]', context).val()) {
-            return Drupal.t('No paths');
+          if (toggle == 'exclude listed') {
+            if (!values) {
+              str = 'All !plural';
+            }
+            else {
+              str = 'All !plural except !adjective !plural';
+            }
           }
           else {
-            return Drupal.t('Only listed paths');
+            if (!values) {
+              str = 'No !plural';
+            }
+            else {
+              str = 'Only !adjective !plural';
+            }
           }
+          const args = {'!plural': plural, '!adjective': adjective};
+          return Drupal.t(Drupal.formatString(str, args));
         }
-      });
+      }
 
-      $('details#edit-role', context).drupalSetSummary(function (context) {
-        var vals = [];
-        $('input[type="checkbox"]:checked', context).each(function () {
-          vals.push($.trim($(this).next('label').text()));
-        });
-        var $radio = $('input[name="role_toggle"]:checked', context);
-        if ($radio.val() == 'exclude listed') {
-          if (!vals.length) {
-            return Drupal.t('All roles');
-          }
-          else {
-            return Drupal.t('All roles except selected roles');
-          }
-        }
-        else {
-          if (!vals.length) {
-            return Drupal.t('No roles');
-          }
-          else {
-            return Drupal.t('Only selected roles');
-          }
-        }
-      });
+      // @todo Magic to use 'data-drupal-selector' vs. 'details#edit-path'?
+      var element, plural, adjective;
 
-      $('details#edit-status', context).drupalSetSummary(function (context) {
-        var $radio = $('input[name="status_toggle"]:checked', context);
-        if ($radio.val() == 'exclude listed') {
-          if (!$('textarea[name="status_list"]', context).val()) {
-            return Drupal.t('All statuses');
-          }
-          else {
-            return Drupal.t('All statuses except listed statuses');
-          }
-        }
-        else {
-          if (!$('textarea[name="status_list"]', context).val()) {
-            return Drupal.t('No statuses');
-          }
-          else {
-            return Drupal.t('Only listed statuses');
-          }
-        }
-      });
+      element = 'checkbox';
+      adjective = 'selected';
+      var selectors = ['role', 'gtag-domain', 'gtag-language'];
+      for (const selector of selectors) {
+        plural = selector.replace('gtag-', '') + 's';
+        $('[data-drupal-selector="edit-' + selector + '"]', context).drupalSetSummary(toggleValuesSummary(element, plural, adjective));
+      }
+
+      element = 'textarea';
+      adjective = 'listed';
+      selectors = ['path', 'status'];
+      for (const selector of selectors) {
+        plural = selector.replace('gtag-', '').replace('status', 'statuse') + 's';
+        $('[data-drupal-selector="edit-' + selector + '"]', context).drupalSetSummary(toggleValuesSummary(element, plural, adjective));
+      }
     }
   };
 
diff --git a/web/modules/google_tag/src/ConditionBase.php b/web/modules/google_tag/src/ConditionBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..ce9c19e56f34411bf9cbd3621e2d49a7bb89bd84
--- /dev/null
+++ b/web/modules/google_tag/src/ConditionBase.php
@@ -0,0 +1,235 @@
+<?php
+
+namespace Drupal\google_tag;
+
+use Drupal\Component\Render\FormattableMarkup;
+use Drupal\Core\Condition\ConditionInterface;
+use Drupal\Core\Executable\ExecutableManagerInterface;
+use Drupal\Core\Executable\ExecutablePluginBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Form\SubformStateInterface;
+use Drupal\Core\Plugin\ContextAwarePluginAssignmentTrait;
+
+/**
+ * Provides a basis for fulfilling contexts for condition plugins.
+ *
+ * @see \Drupal\Core\Condition\Annotation\Condition
+ * @see \Drupal\Core\Condition\ConditionInterface
+ * @see \Drupal\Core\Condition\ConditionManager
+ *
+ * @ingroup plugin_api
+ */
+abstract class ConditionBase extends ExecutablePluginBase implements ConditionInterface {
+
+  use ContextAwarePluginAssignmentTrait;
+
+  /**
+   * The condition manager to proxy execute calls through.
+   *
+   * @var \Drupal\Core\Executable\ExecutableManagerInterface
+   */
+  protected $executableManager;
+
+  /**
+   * The toggle element name.
+   *
+   * @var string
+   */
+  protected $toggle;
+
+  /**
+   * The list element name.
+   *
+   * @var string
+   */
+  protected $list;
+
+  /**
+   * The singular form of condition type.
+   *
+   * @var string
+   */
+  protected $singular;
+
+  /**
+   * The plural form of condition type.
+   *
+   * @var string
+   */
+  protected $plural;
+
+  /**
+   * The options for the list element.
+   *
+   * @var array
+   */
+  protected $options = [];
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+
+    $this->setConfiguration($configuration);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getConfiguration() {
+    return [
+      'id' => $this->getPluginId(),
+    ] + $this->configuration;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setConfiguration(array $configuration) {
+    $this->configuration = $configuration + $this->defaultConfiguration();
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setExecutableManager(ExecutableManagerInterface $executableManager) {
+    $this->executableManager = $executableManager;
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function defaultConfiguration() {
+    return [$this->toggle => GOOGLE_TAG_EXCLUDE_LISTED, $this->list => []];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isNegated() {
+    return $this->configuration[$this->toggle] == GOOGLE_TAG_EXCLUDE_LISTED;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
+    // Gather data.
+    if ($form_state instanceof SubformStateInterface) {
+      $form_state = $form_state->getCompleteFormState();
+    }
+    $contexts = $form_state->getTemporaryValue('gathered_contexts') ?: [];
+
+    // Build form elements.
+    $form[$this->toggle] = [
+      '#type' => 'radios',
+      '#title' => $this->specialT('Insert snippet for specific @plural'),
+      '#options' => [
+        GOOGLE_TAG_EXCLUDE_LISTED => $this->specialT('All @plural except the selected @plural'),
+        GOOGLE_TAG_INCLUDE_LISTED => $this->specialT('Only the selected @plural'),
+      ],
+      '#default_value' => $this->configuration[$this->toggle],
+    ];
+
+    $form[$this->list] = [
+      '#type' => 'checkboxes',
+      '#title' => $this->specialT('Selected @plural'),
+      '#options' => $this->options,
+      '#default_value' => $this->configuration[$this->list],
+    ];
+
+    $form['context_mapping'] = $this->addContextAssignmentElement($this, $contexts);
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
+    $this->configuration[$this->toggle] = $form_state->getValue($this->toggle);
+    $this->configuration[$this->list] = array_filter($form_state->getValue($this->list));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
+    if ($form_state->hasValue('context_mapping')) {
+      $this->setContextMapping($form_state->getValue('context_mapping'));
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function execute() {
+    // @todo Remove this routine? It does the quirky negate.
+    return $this->executableManager->execute($this);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function evaluate() {
+    // @todo Convert a string list of items to an array and reuse this code.
+    $toggle = $this->configuration[$this->toggle];
+    $values = $this->configuration[$this->list];
+
+    if (empty($values)) {
+      $satisfied = $this->isNegated();
+    }
+    else {
+      $satisfied = in_array($this->contextToEvaluate(), $values);
+      $satisfied = $this->isNegated() ? !$satisfied : $satisfied;
+    }
+    return $satisfied;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function calculateDependencies() {
+    return [];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function summary() {
+    $string = 'The @singular is @adverb@verb "@list".';
+    $args = [
+      '@singular' => $this->singular,
+      '@adverb' => $this->isNegated() ? 'not ' : '',
+      '@verb' => count($this->values) > 1 ? 'in' : '',
+    ];
+    return $this->t(strtr($string, $args), ['@list' => implode(', ', $this->values)]);
+  }
+
+  /**
+   * Returns a TranslatableMarkup object after placeholder substitution.
+   *
+   * @param string $string
+   *   The string to manipulate.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup
+   *   The markup object.
+   */
+  public function specialT($string) {
+    return $this->t(strtr($string, ['@plural' => $this->plural]));
+  }
+
+  /**
+   * Returns the entity ID of the context value.
+   *
+   * @return string
+   *   The entity ID of the context value.
+   */
+  public function contextToEvaluate() {
+    return '';
+  }
+
+}
diff --git a/web/modules/google_tag/src/ContainerAccessControlHandler.php b/web/modules/google_tag/src/ContainerAccessControlHandler.php
new file mode 100644
index 0000000000000000000000000000000000000000..a385fa26c72538c7f8f62b3316a390b368dc9f7a
--- /dev/null
+++ b/web/modules/google_tag/src/ContainerAccessControlHandler.php
@@ -0,0 +1,234 @@
+<?php
+
+namespace Drupal\google_tag;
+
+use Drupal\Component\Plugin\Exception\ContextException;
+use Drupal\Component\Plugin\Exception\MissingValueContextException;
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\Cache\CacheableDependencyInterface;
+// use Drupal\Core\Condition\ConditionAccessResolverTrait;
+use Drupal\Core\Condition\ConditionInterface;
+use Drupal\Core\Entity\EntityAccessControlHandler;
+use Drupal\Core\Entity\EntityHandlerInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Plugin\Context\ContextHandlerInterface;
+use Drupal\Core\Plugin\Context\ContextRepositoryInterface;
+use Drupal\Core\Plugin\ContextAwarePluginInterface;
+use Drupal\Core\Session\AccountInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Defines access control for the container configuration entity type.
+ *
+ * @see \Drupal\google_tag\Entity\Container
+ */
+class ContainerAccessControlHandler extends EntityAccessControlHandler implements EntityHandlerInterface {
+
+  // Comment next to declare resolveConditions() here.
+  // use ConditionAccessResolverTrait;
+
+  /**
+   * The plugin context handler.
+   *
+   * @var \Drupal\Core\Plugin\Context\ContextHandlerInterface
+   */
+  protected $contextHandler;
+
+  /**
+   * The context manager service.
+   *
+   * @var \Drupal\Core\Plugin\Context\ContextRepositoryInterface
+   */
+  protected $contextRepository;
+
+  /**
+   * Constructs a container access control handler.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   The entity type definition.
+   * @param \Drupal\Core\Plugin\Context\ContextHandlerInterface $context_handler
+   *   The ContextHandler for applying contexts to conditions properly.
+   * @param \Drupal\Core\Plugin\Context\ContextRepositoryInterface $context_repository
+   *   The lazy context repository service.
+   */
+  public function __construct(EntityTypeInterface $entity_type, ContextHandlerInterface $context_handler, ContextRepositoryInterface $context_repository) {
+    parent::__construct($entity_type);
+    $this->contextHandler = $context_handler;
+    $this->contextRepository = $context_repository;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
+    return new static(
+      $entity_type,
+      $container->get('context.handler'),
+      $container->get('context.repository')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) {
+    if ($operation != 'view') {
+      return parent::checkAccess($entity, $operation, $account);
+    }
+    if (!$entity->status()) {
+      // Deny access to disabled containers.
+      return AccessResult::forbidden()->addCacheableDependency($entity);
+    }
+
+   // @todo Why is this not default code for an entity that uses the condition
+   // plugin interface? Most of it applies generally.
+
+    // Store entity to have access in resolveConditions().
+    /** @var \Drupal\google_tag\Entity\Container $entity */
+    $this->entity = $entity;
+
+    $conditions = [];
+    $missing_context = FALSE;
+    $missing_value = FALSE;
+    foreach ($entity->getInsertionConditions() as $condition_id => $condition) {
+      if ($condition instanceof ContextAwarePluginInterface) {
+        try {
+          $contexts = $this->contextRepository->getRuntimeContexts(array_values($condition->getContextMapping()));
+          $this->contextHandler->applyContextMapping($condition, $contexts);
+        }
+        catch (MissingValueContextException $e) {
+          $missing_value = TRUE;
+        }
+        catch (ContextException $e) {
+          $missing_context = TRUE;
+        }
+      }
+      $conditions[$condition_id] = $condition;
+    }
+
+    if ($missing_context) {
+      // Because cacheable metadata might be missing, forbid cache write.
+      $access = AccessResult::forbidden()->setCacheMaxAge(0);
+    }
+    elseif ($missing_value) {
+      // The contexts exist but have no value. Allow access without
+      // disabling caching. For example the node type condition will have a
+      // missing context on any non-node route like the frontpage.
+      $access = AccessResult::allowed();
+    }
+    elseif ($this->resolveConditions($conditions, 'and') !== FALSE) {
+      $access = AccessResult::allowed();
+    }
+    else {
+      $reason = count($conditions) > 1
+        ? "One of the container insertion conditions ('%s') denied access."
+        : "The container insertion condition '%s' denied access.";
+      $access = AccessResult::forbidden(sprintf($reason, implode("', '", array_keys($conditions))));
+    }
+
+    $this->mergeCacheabilityFromConditions($access, $conditions);
+
+    // Ensure access is re-evaluated when the container changes.
+    return $access->addCacheableDependency($entity);
+  }
+
+  /**
+   * Merges cacheable metadata from conditions onto the access result object.
+   *
+   * @param \Drupal\Core\Access\AccessResult $access
+   *   The access result object.
+   * @param \Drupal\Core\Condition\ConditionInterface[] $conditions
+   *   List of insertion conditions.
+   */
+  protected function mergeCacheabilityFromConditions(AccessResult $access, array $conditions) {
+    foreach ($conditions as $condition) {
+      if ($condition instanceof CacheableDependencyInterface) {
+        $access->addCacheTags($condition->getCacheTags());
+        $access->addCacheContexts($condition->getCacheContexts());
+        $access->setCacheMaxAge(Cache::mergeMaxAges($access->getCacheMaxAge(), $condition->getCacheMaxAge()));
+      }
+    }
+  }
+
+  /**
+   * Override the resolveConditions() routine to avoid these calls:
+   *   $condition->execute()
+   *   $this->executableManager->execute($this);
+   * on plugins defined by this module.
+   *
+   * The latter plugins omit the 'negate' configuration item and unlike core do
+   * not treat an empty list of values in an inconsistent manner. With an empty
+   * list core toggles the 'negate' value. For example, with negate = FALSE core
+   * treats the condition as 'insert snippet except on listed items' and the
+   * latter is empty so access is TRUE. With our plugins this configuration
+   * equates to 'insert snippet only on listed items' and the latter is empty so
+   * access is FALSE.
+   *
+   * Drupal/Core/Condition/ConditionManager::execute()
+   *   $result = $condition->evaluate();
+   *   return $condition->isNegated() ? !$result : $result;
+   *
+   * Core evaluate() routines do NOT return what the documentation comment
+   * indicates because the final negate processing is provided by the condition
+   * manager execute() routine. This misplaced code is NOT a best practice; and
+   * is confusing to someone trying to create a condition plugin.
+   *
+   * For whatever benefits OOP provides, it still does NOT allow for changes to
+   * or replacement of a base class. For example core sets the 'negate' item in
+   * the default configuration and expects it to exist in other routines. This
+   * prevents a child class from easily changing or removing this item. For this
+   * and the above reason, this module replaces the ConditionPluginBase class.
+   * Core does not provide a mechanism to replace many of its services such that
+   * the new base class can be extended by the existing child classes.
+   */
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function resolveConditions(array $conditions, $condition_logic) {
+    foreach ($conditions as $condition_id => $condition) {
+      try {
+        if (in_array($condition_id, ['gtag_domain', 'gtag_language'])) {
+          // Avoid call to execute() as it involves the 'negate' element removed
+          // from our condition plugins.
+          $pass = $condition->evaluate();
+        }
+        else {
+          // The condition plugin is not defined by this module.
+          $pass = $condition->execute();
+        }
+      }
+      catch (ContextException $e) {
+        // The condition is missing context; consider that a pass.
+        // Example: node bundle condition and the page request is not a node.
+        // Because the context is missing, the condition is not applicable.
+        $pass = TRUE;
+      }
+
+      $this->entity->displayMessage('@condition check @satisfied', ['@condition' => str_replace('gtag_', '', $condition_id), '@satisfied' => $pass]);
+
+      if (!$pass && $condition_logic == 'and') {
+        // This condition failed and all conditions are needed; deny access.
+        return FALSE;
+      }
+      elseif ($pass && $condition_logic == 'or') {
+        // This condition passed and only one condition is needed; grant access.
+        return TRUE;
+      }
+    }
+
+    // Return TRUE if logic was 'and', meaning all rules passed.
+    // Return FALSE if logic was 'or', meaning no rule passed.
+    return $condition_logic == 'and';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function checkCreateAccess(AccountInterface $account, array $context, $entity_bundle = NULL) {
+    return AccessResult::allowed();
+  }
+
+}
diff --git a/web/modules/google_tag/src/ContainerController.php b/web/modules/google_tag/src/ContainerController.php
new file mode 100644
index 0000000000000000000000000000000000000000..e7856c711af2a676f41fd640485fe50051534b9d
--- /dev/null
+++ b/web/modules/google_tag/src/ContainerController.php
@@ -0,0 +1,79 @@
+<?php
+
+namespace Drupal\google_tag;
+
+use Drupal\Core\Entity\Controller\EntityController;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Routing\RouteMatchInterface;
+
+use Drupal\google_tag\Entity\Container;
+use Symfony\Component\HttpFoundation\RedirectResponse;
+
+/**
+ * Controller for the container configuration entity type.
+ */
+class ContainerController extends EntityController {
+
+  /**
+   * Route title callback.
+   *
+   * @param string $entity_type_id
+   *   The entity type ID.
+   *
+   * @return string
+   *   The title for the add entity page.
+   */
+  public function addTitle($entity_type_id) {
+    $entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
+    return $this->t('Add @entity-type', ['@entity-type' => $entity_type->getLowercaseLabel()]);
+  }
+
+  /**
+   * Route title callback.
+   *
+   * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
+   *   The route match.
+   * @param \Drupal\Core\Entity\EntityInterface $_entity
+   *   (optional) An entity, passed in directly from the request attributes.
+   *
+   * @return string|null
+   *   The title for the entity edit page, if an entity was found.
+   */
+  public function editTitle(RouteMatchInterface $route_match, EntityInterface $_entity = NULL) {
+    if ($entity = $this->doGetEntity($route_match, $_entity)) {
+      return $this->t('Edit %label container', ['%label' => $entity->label()]);
+    }
+  }
+
+  /**
+   * Enables a Container object.
+   *
+   * @param \Drupal\google_tag\Entity\Container $google_tag_container
+   *   The Container object to enable.
+   *
+   * @return \Symfony\Component\HttpFoundation\RedirectResponse
+   *   A redirect response to the google_tag_container listing page.
+   *
+   * @todo The parameter name must match that used in routing.yml although the
+   *   documentation suggests otherwise.
+   */
+  public function enable(Container $google_tag_container) {
+    $google_tag_container->enable()->save();
+    return new RedirectResponse($google_tag_container->toUrl('collection', ['absolute' => TRUE])->toString());
+  }
+
+  /**
+   * Disables a Container object.
+   *
+   * @param \Drupal\google_tag\Entity\Container $google_tag_container
+   *   The Container object to disable.
+   *
+   * @return \Symfony\Component\HttpFoundation\RedirectResponse
+   *   A redirect response to the google_tag_container listing page.
+   */
+  public function disable(Container $google_tag_container) {
+    $google_tag_container->disable()->save();
+    return new RedirectResponse($google_tag_container->toUrl('collection', ['absolute' => TRUE])->toString());
+  }
+
+}
diff --git a/web/modules/google_tag/src/ContainerListBuilder.php b/web/modules/google_tag/src/ContainerListBuilder.php
new file mode 100644
index 0000000000000000000000000000000000000000..cbbf44da0839a17524f4b6b31c22042f15c4b4b8
--- /dev/null
+++ b/web/modules/google_tag/src/ContainerListBuilder.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace Drupal\google_tag;
+
+use Drupal\Core\Config\Entity\ConfigEntityListBuilder;
+use Drupal\Core\Entity\EntityInterface;
+
+/**
+ * Defines a listing of container configuration entities.
+ *
+ * @see \Drupal\google_tag\Entity\Container
+ */
+class ContainerListBuilder extends ConfigEntityListBuilder {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildHeader() {
+    $header['label'] = t('Label');
+    $header['id'] = t('Machine name');
+    $header['container_id'] = t('Container ID');
+    $header['weight'] = t('Weight');
+    return $header + parent::buildHeader();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildRow(EntityInterface $entity) {
+    // @todo Add JS for drag handle on weight.
+    $row['label'] = $entity->label();
+    $row['id'] = $entity->id();
+    $row['container_id'] = $entity->get('container_id');
+    $row['weight'] = $entity->get('weight');
+    return $row + parent::buildRow($entity);
+  }
+
+}
diff --git a/web/modules/google_tag/src/Entity/Container.php b/web/modules/google_tag/src/Entity/Container.php
new file mode 100644
index 0000000000000000000000000000000000000000..851e77e7393e83fae1444592d081f50b052374ba
--- /dev/null
+++ b/web/modules/google_tag/src/Entity/Container.php
@@ -0,0 +1,704 @@
+<?php
+
+namespace Drupal\google_tag\Entity;
+
+use Drupal\Component\Render\FormattableMarkup;
+use Drupal\Core\Condition\ConditionPluginCollection;
+use Drupal\Core\Config\Entity\ConfigEntityBase;
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\Core\Entity\EntityWithPluginCollectionInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * Defines the container configuration entity.
+ *
+ * @ConfigEntityType(
+ *   id = "google_tag_container",
+ *   label = @Translation("Container configuration"),
+ *   handlers = {
+ *     "storage" = "Drupal\Core\Config\Entity\ConfigEntityStorage",
+ *     "list_builder" = "Drupal\google_tag\ContainerListBuilder",
+ *     "form" = {
+ *       "default" = "Drupal\google_tag\Form\ContainerForm",
+ *       "delete" = "Drupal\Core\Entity\EntityDeleteForm"
+ *     },
+ *     "access" = "Drupal\google_tag\ContainerAccessControlHandler"
+ *   },
+ *   admin_permission = "administer google tag manager",
+ *   config_prefix = "container",
+ *   entity_keys = {
+ *     "id" = "id",
+ *     "label" = "label",
+ *     "status" = "status"
+ *   },
+ *   config_export = {
+ *     "id",
+ *     "label",
+ *     "weight",
+ *     "container_id",
+ *     "data_layer",
+ *     "include_classes",
+ *     "whitelist_classes",
+ *     "blacklist_classes",
+ *     "include_environment",
+ *     "environment_id",
+ *     "environment_token",
+ *     "path_toggle",
+ *     "path_list",
+ *     "role_toggle",
+ *     "role_list",
+ *     "status_toggle",
+ *     "status_list",
+ *     "conditions",
+ *   },
+ *   links = {
+ *     "add-form" = "/admin/config/system/google-tag/add",
+ *     "edit-form" = "/admin/config/system/google-tag/manage/{google_tag_container}",
+ *     "delete-form" = "/admin/config/system/google-tag/manage/{google_tag_container}/delete",
+ *     "enable" = "/admin/config/system/google-tag/manage/{google_tag_container}/enable",
+ *     "disable" = "/admin/config/system/google-tag/manage/{google_tag_container}/disable",
+ *     "collection" = "/admin/config/system/google-tag",
+ *   }
+ * )
+ *
+ * @todo Add a clone operation.
+ * this may not be an option in above annotation
+ *     "clone-form" = "/admin/structure/google_tag/manage/{google_tag_container}/clone",
+ */
+class Container extends ConfigEntityBase implements ConfigEntityInterface, EntityWithPluginCollectionInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The machine name for the configuration entity.
+   *
+   * @var string
+   */
+  protected $id;
+
+  /**
+   * The human-readable name of the configuration entity.
+   *
+   * @var string
+   */
+  public $label;
+
+  /**
+   * The weight of the configuration entity.
+   *
+   * @var int
+   */
+  public $weight = 0;
+
+  /**
+   * The Google Tag Manager container id.
+   *
+   * @var string
+   */
+  public $container_id;
+
+  /**
+   * The name of the data layer.
+   *
+   * @var string
+   */
+  public $data_layer;
+
+  /**
+   * Whether to add the listed classes to the data layer.
+   *
+   * @var bool
+   */
+  public $include_classes;
+
+  /**
+   * The white-listed classes.
+   *
+   * @var string
+   */
+  public $whitelist_classes;
+
+  /**
+   * The black-listed classes.
+   *
+   * @var string
+   */
+  public $blacklist_classes;
+
+  /**
+   * Whether to include the environment items in the applicable snippets.
+   *
+   * @var bool
+   */
+  public $include_environment;
+
+  /**
+   * The environment ID.
+   *
+   * @var string
+   */
+  public $environment_id;
+
+  /**
+   * The environment token.
+   *
+   * @var string
+   */
+  public $environment_token;
+
+  /**
+   * Whether to include or exclude the listed paths.
+   *
+   * @var string
+   */
+  public $path_toggle;
+
+  /**
+   * The listed paths.
+   *
+   * @var string
+   */
+  public $path_list;
+
+  /**
+   * Whether to include or exclude the listed roles.
+   *
+   * @var string
+   */
+  public $role_toggle;
+
+  /**
+   * The listed roles.
+   *
+   * @var array
+   */
+  public $role_list;
+
+  /**
+   * Whether to include or exclude the listed statuses.
+   *
+   * @var string
+   */
+  public $status_toggle;
+
+  /**
+   * The listed statuses.
+   *
+   * @var string
+   */
+  public $status_list;
+
+  /**
+   * The insertion conditions.
+   *
+   * Each item is the configuration array not the condition object.
+   *
+   * @var array
+   */
+  protected $conditions = [];
+
+  /**
+   * The insertion condition collection.
+   *
+   * @var \Drupal\Core\Condition\ConditionPluginCollection
+   */
+  protected $conditionCollection;
+
+  /**
+   * The condition plugin manager.
+   *
+   * @var \Drupal\Core\Executable\ExecutableManagerInterface
+   */
+  protected $conditionPluginManager;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(array $values, $entity_type) {
+    parent::__construct($values, $entity_type);
+
+    $values = array_diff_key($values, array_flip(['uuid', 'langcode']));
+    if (empty($values)) {
+      // Initialize entity properties from default container settings.
+      $config = \Drupal::config('google_tag.settings');
+      foreach ($config->get('_default_container') as $key => $value) {
+        $this->$key = $value;
+      }
+    }
+  }
+
+  /**
+   * Returns array of JavaScript snippets.
+   *
+   * @return array
+   *   Associative array of snippets keyed by type: script, noscript and
+   *   data_layer.
+   */
+  public function snippets() {
+    $snippets = [
+      'script' => $this->scriptSnippet(),
+      'noscript' => $this->noscriptSnippet(),
+      'data_layer' => $this->dataLayerSnippet(),
+    ];
+    // Allow other modules to alter the snippets.
+    \Drupal::moduleHandler()->alter('google_tag_snippets', $snippets, $this);
+    return $snippets;
+  }
+
+  /**
+   * Returns JavaScript script snippet.
+   *
+   * @return array
+   *   The script snippet.
+   */
+  protected function scriptSnippet() {
+    // Gather data.
+    $compact = \Drupal::config('google_tag.settings')->get('compact_snippet');
+    $container_id = $this->variableClean('container_id');
+    $data_layer = $this->variableClean('data_layer');
+    $query = $this->environmentQuery();
+
+    // Build script snippet.
+    $script = <<<EOS
+(function(w,d,s,l,i){
+
+  w[l]=w[l]||[];
+  w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});
+  var f=d.getElementsByTagName(s)[0];
+  var j=d.createElement(s);
+  var dl=l!='dataLayer'?'&l='+l:'';
+  j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl+'$query';
+  j.async=true;
+  f.parentNode.insertBefore(j,f);
+
+})(window,document,'script','$data_layer','$container_id');
+EOS;
+    if ($compact) {
+      $script = str_replace(["\n", '  '], '', $script);
+    }
+/*
+    $script = <<<EOS
+<!-- Google Tag Manager -->
+$script
+<!-- End Google Tag Manager -->
+EOS;
+*/
+    return $script;
+  }
+
+  /**
+   * Returns JavaScript noscript snippet.
+   *
+   * @return array
+   *   The noscript snippet.
+   */
+  protected function noscriptSnippet() {
+    // Gather data.
+    $compact = \Drupal::config('google_tag.settings')->get('compact_snippet');
+    $container_id = $this->variableClean('container_id');
+    $query = $this->environmentQuery();
+
+    // Build noscript snippet.
+    $noscript = <<<EOS
+<noscript aria-hidden="true"><iframe src="https://www.googletagmanager.com/ns.html?id=$container_id$query"
+ height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
+EOS;
+    if ($compact) {
+      $noscript = str_replace("\n", '', $noscript);
+    }
+/*
+    $noscript = <<<EOS
+<!-- Google Tag Manager -->
+$noscript
+<!-- End Google Tag Manager -->
+EOS;
+*/
+    return $noscript;
+  }
+
+  /**
+   * Returns JavaScript data layer snippet or adds items to data layer.
+   *
+   * @return string|null
+   *   The data layer snippet or NULL.
+   */
+  protected function dataLayerSnippet() {
+    // Gather data.
+    $data_layer = $this->variableClean('data_layer');
+    $whitelist = $this->get('whitelist_classes');
+    $blacklist = $this->get('blacklist_classes');
+
+    $classes = [];
+    $names = ['whitelist', 'blacklist'];
+    foreach ($names as $name) {
+      if (empty($$name)) {
+        continue;
+      }
+      // @see https://www.drupal.org/files/issues/add_options_to-2851405-7.patch
+      // this suggests to flip order of previous two statements; yet if user
+      // enters a new line in textarea, then this change does not eliminate the
+      // empty script item. Need to trim "\n" from ends of string.
+      $$name = explode("\n", $$name);
+      $classes["gtm.$name"] = $$name;
+    }
+
+    if ($classes) {
+      // Build data layer snippet.
+      $script = "var $data_layer = [" . json_encode($classes) . '];';
+      return $script;
+    }
+  }
+
+  /**
+   * Returns a query string with the environment parameters.
+   *
+   * @return string
+   *   The query string.
+   */
+  public function environmentQuery() {
+    if (!$this->get('include_environment')) {
+      return '';
+    }
+
+    // Gather data.
+    $environment_id = $this->variableClean('environment_id');
+    $environment_token = $this->variableClean('environment_token');
+
+    // Build query string.
+    return "&gtm_auth=$environment_token&gtm_preview=$environment_id&gtm_cookies_win=x";
+  }
+
+  /**
+   * Returns a cleansed variable.
+   *
+   * @param string $variable
+   *   The variable name.
+   *
+   * @return string
+   *   The cleansed variable.
+   */
+  public function variableClean($variable) {
+    return trim(json_encode($this->get($variable)), '"');
+  }
+
+  /**
+   * Determines whether to insert the snippet on the response.
+   *
+   * @return bool
+   *   TRUE if the conditions are met; FALSE otherwise.
+   */
+  public function insertSnippet() {
+    static $satisfied = [];
+
+    if (!isset($satisfied[$this->id])) {
+      $id = $this->get('container_id');
+
+      if (empty($id)) {
+        // No container ID.
+        return $satisfied[$this->id] = FALSE;
+      }
+
+      $this->displayMessage('google_tag container ' . $this->id);
+      $satisfied[$this->id] = TRUE;
+      if (!$this->statusCheck() || !$this->pathCheck() || !$this->roleCheck() || !$this->access('view')) {
+        // Omit snippet if any condition is not met.
+        $satisfied[$this->id] = FALSE;
+      }
+
+      // Allow other modules to alter the insertion criteria.
+      \Drupal::moduleHandler()->alter('google_tag_insert', $satisfied[$this->id], $this);
+      $this->displayMessage('after alter @satisfied', ['@satisfied' => $satisfied[$this->id]]);
+    }
+    return $satisfied[$this->id];
+  }
+
+  /**
+   * Determines whether to insert the snippet based on status code settings.
+   *
+   * @return bool
+   *   TRUE if the status conditions are met; FALSE otherwise.
+   */
+  protected function statusCheck() {
+    $toggle = $this->get('status_toggle');
+    $statuses = $this->get('status_list');
+
+    if (empty($statuses)) {
+      $satisfied = ($toggle == GOOGLE_TAG_EXCLUDE_LISTED);
+    }
+    else {
+      // Get the HTTP response status.
+      $request = \Drupal::request();
+      $status = '200';
+      if ($exception = $request->attributes->get('exception')) {
+        $status = $exception->getStatusCode();
+      }
+      $satisfied = strpos($statuses, (string) $status) !== FALSE;
+      $satisfied = ($toggle == GOOGLE_TAG_EXCLUDE_LISTED) ? !$satisfied : $satisfied;
+    }
+    $this->displayMessage('status check @satisfied', ['@satisfied' => $satisfied]);
+    return $satisfied;
+  }
+
+  /**
+   * Determines whether to insert the snippet based on the path settings.
+   *
+   * @return bool
+   *   TRUE if the path conditions are met; FALSE otherwise.
+   */
+  protected function pathCheck() {
+    $toggle = $this->get('path_toggle');
+    $paths = mb_strtolower($this->get('path_list'));
+
+    if (empty($paths)) {
+      $satisfied = ($toggle == GOOGLE_TAG_EXCLUDE_LISTED);
+    }
+    else {
+      $request = \Drupal::request();
+      $current_path = \Drupal::service('path.current');
+      $alias_manager = \Drupal::service('path.alias_manager');
+      $path_matcher = \Drupal::service('path.matcher');
+      // @todo Are not some paths case sensitive???
+      // Compare the lowercase path alias (if any) and internal path.
+      $path = $current_path->getPath($request);
+      $path_alias = mb_strtolower($alias_manager->getAliasByPath($path));
+      $satisfied = $path_matcher->matchPath($path_alias, $paths) || (($path != $path_alias) && $path_matcher->matchPath($path, $paths));
+      $satisfied = ($toggle == GOOGLE_TAG_EXCLUDE_LISTED) ? !$satisfied : $satisfied;
+    }
+    $this->displayMessage('path check @satisfied', ['@satisfied' => $satisfied]);
+    return $satisfied;
+  }
+
+  /**
+   * Determines whether to insert the snippet based on the user role settings.
+   *
+   * @return bool
+   *   TRUE if the role conditions are met; FALSE otherwise.
+   */
+  protected function roleCheck() {
+    $toggle = $this->get('role_toggle');
+    $roles = array_filter($this->get('role_list'));
+
+    if (empty($roles)) {
+      $satisfied = ($toggle == GOOGLE_TAG_EXCLUDE_LISTED);
+    }
+    else {
+      $satisfied = FALSE;
+      // Check user roles against listed roles.
+      $satisfied = (bool) array_intersect($roles, \Drupal::currentUser()->getRoles());
+      $satisfied = ($toggle == GOOGLE_TAG_EXCLUDE_LISTED) ? !$satisfied : $satisfied;
+    }
+    $this->displayMessage('role check @satisfied', ['@satisfied' => $satisfied]);
+    return $satisfied;
+  }
+
+  /**
+   * Displays a message.
+   *
+   * @param string $message
+   *   The message to display.
+   * @param array $args
+   *   (optional) An associative array of replacements.
+   */
+  public function displayMessage($message, array $args = []) {
+    if (\Drupal::config('google_tag.settings')->get('debug_output')) {
+      \Drupal::service('messenger')->addStatus($this->t($message, $args), TRUE);
+    }
+  }
+
+  /**
+   * Returns the snippet directory path.
+   *
+   * @return string
+   *   The snippet directory path.
+   */
+  public function snippetDirectory() {
+    return \Drupal::config('google_tag.settings')->get('uri') . "/google_tag/{$this->id()}";
+  }
+
+  /**
+   * Returns the snippet URI for a snippet type.
+   *
+   * @param string $type
+   *   The snippet type.
+   *
+   * @return string
+   *   The snippet URI.
+   */
+  public function snippetURI($type) {
+    return $this->snippetDirectory() . "/google_tag.$type.js";
+  }
+
+  /**
+   * Returns tag array for the snippet type.
+   *
+   * @param string $type
+   *   The snippet type.
+   * @param int $weight
+   *   The weight of the item.
+   *
+   * @return array
+   *   The tag array.
+   */
+  public function fileTag($type, $weight) {
+    $uri = $this->snippetURI($type);
+    $url = file_url_transform_relative(file_create_url($uri));
+    $query_string = \Drupal::state()->get('system.css_js_query_string') ?: '0';
+    $attachment = [
+      [
+        '#type' => 'html_tag',
+        '#tag' => 'script',
+        '#attributes' => ['src' => $url . '?' . $query_string, 'defer' => TRUE],
+        '#weight' => $weight,
+      ],
+      "google_tag_{$type}_tag__{$this->id()}",
+    ];
+    return $attachment;
+  }
+
+  /**
+   * Returns tag array for the snippet type.
+   *
+   * @param string $type
+   *   The snippet type.
+   * @param int $weight
+   *   The weight of the item.
+   *
+   * @return array
+   *   The tag array.
+   */
+  public function inlineTag($type, $weight) {
+    $uri = $this->snippetURI($type);
+    $url = \Drupal::service('file_system')->realpath($uri);
+    $contents = @file_get_contents($url);
+    $attachment = [
+      $contents ? [
+        '#type' => 'html_tag',
+        '#tag' => 'script',
+        '#value' => new FormattableMarkup($contents, []),
+        '#weight' => $weight,
+      ]
+      : ['#type' => 'ignore_tag'],
+      "google_tag_{$type}_tag__{$this->id()}",
+    ];
+    return $attachment;
+  }
+
+  /**
+   * Returns tag array for the snippet type.
+   *
+   * @param string $type
+   *   (optional) The snippet type.
+   * @param int $weight
+   *   (optional) The weight of the item.
+   *
+   * @return array
+   *   The tag array.
+   */
+  public function noscriptTag($type = 'noscript', $weight = -10) {
+    // Note: depending on the theme, this may not place the snippet immediately
+    // after the body tag but should be close and it can be altered.
+
+    // @see https://api.drupal.org/api/drupal/core!lib!Drupal!Core!Render!theme.api.php/group/theme_render/8.2.x
+    // The markup is passed through \Drupal\Component\Utility\Xss::filterAdmin()
+    // which strips known vectors while allowing a permissive list of HTML tags
+    // that are not XSS vectors. (e.g., <script> and <style> are not allowed.)
+    // As markup, core removes the 'style' attribute from the noscript snippet.
+    // With the inline template type, core does not alter the noscript snippet.
+
+    $uri = $this->snippetURI($type);
+    $url = \Drupal::service('file_system')->realpath($uri);
+    $contents = @file_get_contents($url);
+    $attachment = $contents ? [
+      "google_tag_{$type}_tag__{$this->id()}" => [
+        '#type' => 'inline_template',
+        '#template' => $contents,
+        '#weight' => $weight,
+      ],
+    ] : [];
+    return $attachment;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getPluginCollections() {
+    return [
+      'conditions' => $this->getInsertionConditions(),
+    ];
+  }
+
+  /**
+   * Returns an array of configuration arrays keyed by insertion condition.
+   *
+   * @return array
+   *   An array of condition configuration keyed by the condition ID.
+   */
+  public function getInsertionConfiguration() {
+    return $this->getInsertionConditions()->getConfiguration();
+  }
+
+  /**
+   * Returns an insertion condition for this container.
+   *
+   * @param string $instance_id
+   *   The condition plugin instance ID.
+   *
+   * @return \Drupal\Core\Condition\ConditionInterface
+   *   A condition plugin.
+   */
+  public function getInsertionCondition($instance_id) {
+    return $this->getInsertionConditions()->get($instance_id);
+  }
+
+  /**
+   * Sets the configuration for an insertion condition.
+   *
+   * @param string $instance_id
+   *   The condition instance ID.
+   * @param array $configuration
+   *   The condition configuration.
+   *
+   * @return $this
+   *
+   * @todo Does this need to set a persistent property?
+   */
+  public function setInsertionCondition($instance_id, array $configuration) {
+    $conditions = $this->getInsertionConditions();
+    if (!$conditions->has($instance_id)) {
+      $configuration['id'] = $instance_id;
+      $conditions->addInstanceId($instance_id, $configuration);
+    }
+    else {
+      $conditions->setInstanceConfiguration($instance_id, $configuration);
+    }
+    return $this;
+  }
+
+  /**
+   * Returns the set of insertion conditions for this container.
+   *
+   * @return \Drupal\Core\Condition\ConditionPluginCollection
+   *   A collection of configured condition plugins.
+   */
+  public function getInsertionConditions() {
+    if (!isset($this->conditionCollection)) {
+      $this->conditionCollection = new ConditionPluginCollection($this->conditionPluginManager(), $this->get('conditions'));
+    }
+    return $this->conditionCollection;
+  }
+
+  /**
+   * Gets the condition plugin manager.
+   *
+   * @return \Drupal\Core\Executable\ExecutableManagerInterface
+   *   The condition plugin manager.
+   */
+  protected function conditionPluginManager() {
+    if (!isset($this->conditionPluginManager)) {
+      $this->conditionPluginManager = \Drupal::service('plugin.manager.condition');
+    }
+    return $this->conditionPluginManager;
+  }
+
+}
diff --git a/web/modules/google_tag/src/Entity/ContainerManager.php b/web/modules/google_tag/src/Entity/ContainerManager.php
new file mode 100644
index 0000000000000000000000000000000000000000..805fdfc2b3fe8f8deb00713264d6ba08c58cbc6a
--- /dev/null
+++ b/web/modules/google_tag/src/Entity/ContainerManager.php
@@ -0,0 +1,224 @@
+<?php
+
+namespace Drupal\google_tag\Entity;
+
+use Drupal\google_tag\Entity\ContainerManagerInterface;
+
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\File\FileSystemInterface;
+use Drupal\Core\Logger\LoggerChannelFactoryInterface;
+use Drupal\Core\Messenger\MessengerInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * Defines the Google tag container manager.
+ */
+class ContainerManager implements ContainerManagerInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The module handler.
+   *
+   * @var \Drupal\Core\Extension\ModuleHandlerInterface
+   */
+  protected $moduleHandler;
+
+  /**
+   * The file system.
+  *
+   * @var \Drupal\Core\File\FileSystemInterface
+   */
+  protected $fileSystem;
+
+  /**
+   * The messenger.
+   *
+   * @var \Drupal\Core\Messenger\MessengerInterface
+   */
+  protected $messenger;
+
+  /**
+   * The logger.
+  *
+   * @var \Psr\Log\LoggerInterface
+   */
+  protected $logger;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, ModuleHandlerInterface $module_handler, FileSystemInterface $file_system, MessengerInterface $messenger, LoggerChannelFactoryInterface $logger_factory) {
+    $this->entityTypeManager = $entity_type_manager;
+    $this->moduleHandler = $module_handler;
+    $this->fileSystem = $file_system;
+    $this->messenger = $messenger;
+    $this->logger = $logger_factory->get('google_tag');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function createAssets(ConfigEntityInterface $container) {
+    $result = TRUE;
+    $directory = $container->snippetDirectory();
+    if (!is_dir($directory) || !_google_tag_is_writable($directory) || !_google_tag_is_executable($directory)) {
+      $result = _file_prepare_directory($directory, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS);
+    }
+    if ($result) {
+      $result = $this->saveSnippets($container);
+    }
+    else {
+      $args = ['%directory' => $directory];
+      $message = 'The directory %directory could not be prepared for use, possibly due to file system permissions. The directory either does not exist, or is not writable or searchable.';
+      $this->displayMessage($message, $args, MessengerInterface::TYPE_ERROR);
+      $this->logger->error($message, $args);
+    }
+    return $result;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function saveSnippets(ConfigEntityInterface $container) {
+    // Save the altered snippets after hook_google_tag_snippets_alter().
+    $result = TRUE;
+    $snippets = $container->snippets();
+    foreach ($snippets as $type => $snippet) {
+      $uri = $container->snippetURI($type);
+      $path = $this->fileSystem->saveData($snippet, $uri, FileSystemInterface::EXISTS_REPLACE);
+      $result = !$path ? FALSE : $result;
+    }
+    $args = ['@count' => count($snippets), '%container' => $container->get('label')];
+    if (!$result) {
+      $message = 'An error occurred saving @count snippet files for %container container. Contact the site administrator if this persists.';
+      $this->displayMessage($message, $args, MessengerInterface::TYPE_ERROR);
+      $this->logger->error($message, $args);
+    }
+    else {
+      $message = 'Created @count snippet files for %container container based on configuration.';
+      $this->displayMessage($message, $args);
+      // Reset the URL query argument so browsers reload snippet files.
+      _drupal_flush_css_js();
+    }
+    return $result;
+  }
+
+  /**
+   * Displays a message to admin users.
+   *
+   * @param string $message
+   *   The message to display.
+   * @param array $args
+   *   (optional) An associative array of replacements.
+   * @param string $type
+   *   (optional) The message type. Defaults to 'status'.
+   */
+  public function displayMessage($message, array $args = [], $type = MessengerInterface::TYPE_STATUS) {
+    global $_google_tag_display_message;
+    if ($_google_tag_display_message) {
+      $this->messenger->addMessage($this->t($message, $args), $type, TRUE);
+    }
+  }
+
+  /**
+   * Returns container entity IDs.
+   *
+   * @return array
+   *   The entity ID array.
+   */
+  public function loadContainerIDs() {
+    return \Drupal::entityQuery('google_tag_container')
+      ->condition('status', 1)
+      ->sort('weight')
+      ->execute();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getScriptAttachments(array &$attachments) {
+    $ids = $this->loadContainerIDs();
+    $containers = $this->entityTypeManager->getStorage('google_tag_container')->loadMultiple($ids);
+    foreach ($containers as $container) {
+      if (!$container->insertSnippet()) {
+        continue;
+      }
+
+      static $weight = 9;
+      $include_script_as_file = \Drupal::config('google_tag.settings')->get('include_file');
+      $include_classes = $container->get('include_classes');
+      // @todo Only want one data_layer snippet even with multiple containers.
+      // If user sorts containers such that the first does not define the data
+      // layer, then validate this or adjust for it here.
+      // Sort the items being added and put the data_layer at top?
+      $types = $include_classes ? ['data_layer', 'script'] : ['script'];
+
+      // Add data_layer and script snippets to head (no longer by default).
+      if ($include_script_as_file) {
+        foreach ($types as $type) {
+          // @todo Will it matter if file is empty?
+          // @todo Check config for the whitelist and blacklist classes before adding.
+          $attachments['#attached']['html_head'][] = $container->fileTag($type, $weight++);
+        }
+      }
+      else {
+        foreach ($types as $type) {
+          // @see drupal_get_js() in 7.x core.
+          // For inline JavaScript to validate as XHTML, all JavaScript containing
+          // XHTML needs to be wrapped in CDATA.
+          $attachments['#attached']['html_head'][] = $container->inlineTag($type, $weight++);
+        }
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getNoScriptAttachments(array &$page) {
+    $ids = $this->loadContainerIDs();
+    $containers = $this->entityTypeManager->getStorage('google_tag_container')->loadMultiple($ids);
+    foreach ($containers as $container) {
+      if (!$container->insertSnippet()) {
+        continue;
+      }
+
+      $page += $container->noscriptTag();
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function createAllAssets() {
+    $ids = $this->loadContainerIDs();
+    if (!$ids) {
+      return;
+    }
+    if (\Drupal::config('google_tag.settings')->get('flush_snippets')) {
+      $directory = \Drupal::config('google_tag.settings')->get('uri');
+      if (!empty($directory)) {
+        // Remove any stale files (e.g. module update or machine name change).
+        $this->fileSystem->deleteRecursive($directory . '/google_tag');
+      }
+    }
+    // Create snippet files for enabled containers.
+    $containers = $this->entityTypeManager->getStorage('google_tag_container')->loadMultiple($ids);
+    $result = TRUE;
+    foreach ($containers as $container) {
+      $result = !$this->createAssets($container) ? FALSE : $result;
+    }
+    return $result;
+  }
+
+}
diff --git a/web/modules/google_tag/src/Entity/ContainerManagerInterface.php b/web/modules/google_tag/src/Entity/ContainerManagerInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..d94403a60870f29db37b19e18fd362037e080409
--- /dev/null
+++ b/web/modules/google_tag/src/Entity/ContainerManagerInterface.php
@@ -0,0 +1,81 @@
+<?php
+
+namespace Drupal\google_tag\Entity;
+
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\File\FileSystemInterface;
+use Drupal\Core\Logger\LoggerChannelFactoryInterface;
+use Drupal\Core\Messenger\MessengerInterface;
+
+/**
+ * Provides an interface for a Google tag container manager.
+ */
+interface ContainerManagerInterface {
+
+  /**
+   * Constructs a ContainerManager.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
+   *   The module handler.
+   * @param \Drupal\Core\File\FileSystemInterface $file_system
+   *   The file system.
+   * @param \Drupal\Core\Messenger\MessengerInterface $messenger
+   *   The messenger.
+   * @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_factory
+   *   The logger factory.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, ModuleHandlerInterface $module_handler, FileSystemInterface $file_system, MessengerInterface $messenger, LoggerChannelFactoryInterface $logger_factory);
+
+  /**
+   * Prepares directory for and saves snippet files for a container.
+   *
+   * @todo Which class-interface to use on @param?
+   *
+   * @param Drupal\Core\Config\Entity\ConfigEntityInterface $container
+   *   The container configuration entity.
+   *
+   * @return bool
+   *   Whether the files were saved.
+   */
+  public function createAssets(ConfigEntityInterface $container);
+
+  /**
+   * Saves JS snippet files based on current settings.
+   *
+   * @param Drupal\Core\Config\Entity\ConfigEntityInterface $container
+   *   The container configuration entity.
+   *
+   * @return bool
+   *   Whether the files were saved.
+   */
+  public function saveSnippets(ConfigEntityInterface $container);
+
+  /**
+   * Adds render array items of page attachments.
+   *
+   * @param array $attachments
+   *   The attachments render array.
+   */
+  public function getScriptAttachments(array &$attachments);
+
+  /**
+   * Adds render array items of page top attachments.
+   *
+   * @param array $page
+   *   The page render array.
+   */
+  public function getNoScriptAttachments(array &$page);
+
+  /**
+   * Prepares directory for and saves snippet files for all containers.
+   *
+   * @return bool
+   *   Whether the files were saved.
+   */
+  public function createAllAssets();
+
+}
diff --git a/web/modules/google_tag/src/Form/ContainerForm.php b/web/modules/google_tag/src/Form/ContainerForm.php
new file mode 100644
index 0000000000000000000000000000000000000000..244721cfc60c229549e6d6f1609acc2cb97f9d62
--- /dev/null
+++ b/web/modules/google_tag/src/Form/ContainerForm.php
@@ -0,0 +1,346 @@
+<?php
+
+namespace Drupal\google_tag\Form;
+
+use Drupal\Core\Condition\ConditionInterface;
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+use Drupal\Core\Entity\EntityForm;
+use Drupal\Core\Executable\ExecutableManagerInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Form\SubformState;
+use Drupal\Core\Plugin\Context\ContextRepositoryInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\HttpFoundation\RedirectResponse;
+
+/**
+ * Defines the Google tag manager container settings form.
+ */
+class ContainerForm extends EntityForm {
+
+  use ContainerTrait;
+
+  /**
+   * The condition plugin manager.
+   *
+   * @var \Drupal\Core\Condition\ConditionManager
+   */
+  protected $conditionManager;
+
+  /**
+   * The context repository service.
+   *
+   * @var \Drupal\Core\Plugin\Context\ContextRepositoryInterface
+   */
+  protected $contextRepository;
+
+  /**
+   * The container entity.
+   *
+   * @var \Drupal\google_tag\Entity\Container
+   */
+  protected $container;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'google_tag_container';
+  }
+
+  /**
+   * Constructs a ContainerForm object.
+   *
+   * @param \Drupal\Core\Executable\ExecutableManagerInterface $condition_manager
+   *   The ConditionManager for building the insertion conditions.
+   * @param \Drupal\Core\Plugin\Context\ContextRepositoryInterface $context_repository
+   *   The lazy context repository service.
+   */
+  public function __construct(ExecutableManagerInterface $condition_manager, ContextRepositoryInterface $context_repository) {
+    $this->conditionManager = $condition_manager;
+    $this->contextRepository = $context_repository;
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * This routine is the trick to DependencyInjection in Drupal. Without it the
+   * __construct method complains of no arguments instead of three.
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('plugin.manager.condition'),
+      $container->get('context.repository')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function form(array $form, FormStateInterface $form_state) {
+    $form = parent::form($form, $form_state);
+    $container = $this->container = $this->entity;
+    $this->prefix = '';
+
+    // Store the contexts for other objects to use during form building.
+    $form_state->setTemporaryValue('gathered_contexts', $this->contextRepository->getAvailableContexts());
+
+    // The main premise of entity forms is that we get to work with an entity
+    // object at all times instead of checking submitted values from the form
+    // state.
+
+    // Build form elements.
+    $form['label'] = [
+      '#type' => 'textfield',
+      '#title' => 'Label',
+      '#default_value' => $container->label(),
+      '#required' => TRUE,
+    ];
+    $form['id'] = [
+      '#type' => 'machine_name',
+      '#default_value' => $container->id(),
+      '#required' => TRUE,
+      '#machine_name' => [
+        'exists' => [$this, 'containerExists'],
+        'replace_pattern' => '[^a-z0-9_.]+',
+      ],
+    ];
+
+    $form['settings'] = [
+      '#type' => 'vertical_tabs',
+      '#title' => $this->t('Container settings'),
+      '#description' => $this->t('The settings affecting the snippet contents for this container.'),
+      '#attributes' => ['class' => ['google-tag']],
+    ];
+
+    $form['conditions'] = [
+      '#type' => 'vertical_tabs',
+      '#title' => $this->t('Insertion conditions'),
+      '#description' => $this->t('The snippet insertion conditions for this container.'),
+      '#attributes' => ['class' => ['google-tag']],
+      '#attached' => [
+        'library' => ['google_tag/drupal.settings_form'],
+      ],
+    ];
+
+    $form['general'] = $this->generalFieldset($form_state);
+    $form['advanced'] = $this->advancedFieldset($form_state);
+    $form['path'] = $this->pathFieldset($form_state);
+    $form['role'] = $this->roleFieldset($form_state);
+    $form['status'] = $this->statusFieldset($form_state);
+
+    $form += $this->conditionsForm([], $form_state);
+
+    $form['actions'] = ['#type' => 'actions'];
+    $form['actions']['submit'] = [
+      '#type' => 'submit',
+      '#value' => 'Save',
+    ];
+    $form['actions']['delete'] = [
+      '#type' => 'submit',
+      '#value' => 'Delete',
+    ];
+
+    return $form;
+  }
+
+  /**
+   * Fieldset builder for the container settings form.
+   */
+  public function generalFieldset(FormStateInterface &$form_state) {
+    $container = $this->entity;
+
+    // Build form elements.
+    $fieldset = [
+      '#type' => 'details',
+      '#title' => $this->t('General'),
+      '#group' => 'settings',
+    ];
+
+    $fieldset['container_id'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Container ID'),
+      '#description' => $this->t('The ID assigned by Google Tag Manager (GTM) for this website container. To get a container ID, <a href="https://tagmanager.google.com/">sign up for GTM</a> and create a container for your website.'),
+      '#default_value' => $container->get('container_id'),
+      '#attributes' => ['placeholder' => ['GTM-xxxxxx']],
+      '#size' => 12,
+      '#maxlength' => 15,
+      '#required' => TRUE,
+    ];
+
+    $fieldset['weight'] = [
+      '#type' => 'weight',
+      '#title' => 'Weight',
+      '#default_value' => $container->get('weight'),
+    ];
+
+    return $fieldset;
+  }
+
+  /**
+   * Builds the form elements for the insertion conditions.
+   *
+   * @param array $form
+   *   An associative array containing the structure of the form.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The current state of the form.
+   *
+   * @return array
+   *   The augmented form array with the insertion condition elements.
+   */
+  protected function conditionsForm(array $form, FormStateInterface $form_state) {
+    $conditions = $this->entity->getInsertionConditions();
+    // See core/lib/Drupal/Core/Plugin/FilteredPluginManagerTrait.php
+    // The next method calls alter hooks to filter the definitions.
+    // Implement one of the hooks in this module.
+    $definitions = $this->conditionManager->getFilteredDefinitions('google_tag', $form_state->getTemporaryValue('gathered_contexts'), ['google_tag_container' => $this->entity]);
+    ksort($definitions);
+    $form_state->setTemporaryValue('filtered_conditions', array_keys($definitions));
+    foreach ($definitions as $condition_id => $definition) {
+      if ($conditions->has($condition_id)) {
+        $condition = $conditions->get($condition_id);
+      }
+      else {
+        /** @var \Drupal\Core\Condition\ConditionInterface $condition */
+        $condition = $this->conditionManager->createInstance($condition_id, []);
+      }
+      $form_state->set(['conditions', $condition_id], $condition);
+      $form[$condition_id] = $this->conditionFieldset($condition, $form_state);
+    }
+/*
+    // Add comment to first condition tab.
+    // @todo This would apply if all insertion conditions were converted to
+    // condition plugins.
+    $description = $this->t('On this and the following tabs, specify the conditions on which the GTM JavaScript snippet will either be inserted on or omitted from the page response, thereby enabling or disabling tracking and other analytics. All conditions must be satisfied for the snippet to be inserted. The snippet will be omitted if any condition is not met.');
+    $condition_id = current(array_keys($definitions));
+    $form[$condition_id]['#description'] = $description;
+*/
+    return $form;
+  }
+
+  /**
+   * Returns the form elements from the condition plugin object.
+   *
+   * @param \Drupal\Core\Condition\ConditionInterface $condition
+   *   The condition plugin.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The current state of the form.
+   *
+   * @return array
+   *   The form array for the insertion condition.
+   */
+  public function conditionFieldset(ConditionInterface $condition, FormStateInterface $form_state) {
+    // Build form elements.
+    $fieldset = [
+      '#type' => 'details',
+      '#title' => $condition->getPluginDefinition()['label'],
+      '#group' => 'conditions',
+      '#tree' => TRUE,
+    ] + $condition->buildConfigurationForm([], $form_state);
+
+    return $fieldset;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateForm(array &$form, FormStateInterface $form_state) {
+    $this->validateFormValues($form, $form_state);
+    parent::validateForm($form, $form_state);
+    $this->validateConditionsForm($form, $form_state);
+  }
+
+  /**
+   * Form validation handler for the insertion conditions.
+   *
+   * @param array $form
+   *   An associative array containing the structure of the form.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The current state of the form.
+   */
+  protected function validateConditionsForm(array $form, FormStateInterface $form_state) {
+    // Validate the insertion condition settings.
+    $condition_ids = $form_state->getTemporaryValue('filtered_conditions');
+    foreach ($condition_ids as $condition_id) {
+      // Allow the condition to validate the form.
+      $condition = $form_state->get(['conditions', $condition_id]);
+      $condition->validateConfigurationForm($form[$condition_id], SubformState::createForSubform($form[$condition_id], $form, $form_state));
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    parent::submitForm($form, $form_state);
+    $this->submitConditionsForm($form, $form_state);
+  }
+
+  /**
+   * Form submission handler for the insertion conditions.
+   *
+   * @param array $form
+   *   An associative array containing the structure of the form.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The current state of the form.
+   */
+  protected function submitConditionsForm(array $form, FormStateInterface $form_state) {
+    $condition_ids = $form_state->getTemporaryValue('filtered_conditions');
+    foreach ($condition_ids as $condition_id) {
+      $values = $form_state->getValue($condition_id);
+      // Allow the condition to submit the form.
+      $condition = $form_state->get(['conditions', $condition_id]);
+      $condition->submitConfigurationForm($form[$condition_id], SubformState::createForSubform($form[$condition_id], $form, $form_state));
+      $configuration = $condition->getConfiguration();
+      // Update the insertion conditions on the container.
+      $this->entity->setInsertionCondition($condition_id, $configuration);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function save(array $form, FormStateInterface $form_state) {
+    // Drupal/Core/Condition/ConditionPluginCollection.php
+    // On save, above class filters any condition with default configuration.
+    // See ::getConfiguration()
+    // The database row omits such conditions from the container 'conditions'.
+    // google_tag/src/ContainerAccessControlHandler.php
+    // On access check, the list of conditions only includes those in database.
+    // Those with default configuration are assumed not to apply as the default
+    // values should produce no restriction.
+    // However, core treats an empty values list opposite this module.
+    parent::save($form, $form_state);
+
+    // @todo This could be done in container::postSave() method.
+    global $_google_tag_display_message;
+    $_google_tag_display_message = TRUE;
+    $manager = \Drupal::service('google_tag.container_manager');
+    $manager->createAssets($this->entity);
+
+    // Redirect to collection page.
+    $form_state->setRedirect('entity.google_tag_container.collection');
+  }
+
+  /**
+   * Checks if a container machine name is taken.
+   *
+   * @param string $value
+   *   The machine name.
+   * @param array $element
+   *   An array containing the structure of the 'id' element.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The current state of the form.
+   *
+   * @return bool
+   *   Whether or not the container machine name is taken.
+   */
+  public function containerExists($value, array $element, FormStateInterface $form_state) {
+    /** @var \Drupal\Core\Config\Entity\ConfigEntityInterface $container */
+    $container = $form_state->getFormObject()->getEntity();
+    return (bool) $this->entityTypeManager->getStorage($container->getEntityTypeId())
+      ->getQuery()
+      ->condition($container->getEntityType()->getKey('id'), $value)
+      ->execute();
+  }
+
+}
diff --git a/web/modules/google_tag/src/Form/ContainerTrait.php b/web/modules/google_tag/src/Form/ContainerTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..e2a312add500eaa70358f6d8ebc318bf9672003d
--- /dev/null
+++ b/web/modules/google_tag/src/Form/ContainerTrait.php
@@ -0,0 +1,312 @@
+<?php
+
+namespace Drupal\google_tag\Form;
+
+use Drupal\Core\Form\FormStateInterface;
+
+trait ContainerTrait {
+
+  /**
+   * Fieldset builder for the container settings form.
+   */
+  public function advancedFieldset(FormStateInterface &$form_state) {
+    $container = $this->container;
+
+    // Build form elements.
+    $fieldset = [
+      '#type' => 'details',
+      '#title' => $this->t('Advanced'),
+      '#group' => 'settings',
+    ];
+
+    $fieldset['data_layer'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Data layer'),
+      '#description' => $this->t('The name of the data layer. Default value is "dataLayer". In most cases, use the default.'),
+      '#default_value' => $container->get("{$this->prefix}data_layer"),
+      '#attributes' => ['placeholder' => ['dataLayer']],
+      '#required' => TRUE,
+    ];
+
+    $fieldset['include_classes'] = [
+      '#type' => 'checkbox',
+      '#title' => $this->t('Add classes to the data layer'),
+      '#description' => $this->t('If checked, then the listed classes will be added to the data layer.'),
+      '#default_value' => $container->get("{$this->prefix}include_classes"),
+    ];
+
+    $description = $this->t('The types of tags, triggers, and variables <strong>allowed</strong> on a page. Enter one class per line. For more information, refer to the <a href="https://developers.google.com/tag-manager/devguide#security">developer documentation</a>.');
+
+    $fieldset['whitelist_classes'] = [
+      '#type' => 'textarea',
+      '#title' => $this->t('White-listed classes'),
+      '#description' => $description,
+      '#default_value' => $container->get("{$this->prefix}whitelist_classes"),
+      '#rows' => 5,
+      '#states' => $this->statesArray('include_classes'),
+    ];
+
+    $fieldset['blacklist_classes'] = [
+      '#type' => 'textarea',
+      '#title' => $this->t('Black-listed classes'),
+      '#description' => $this->t('The types of tags, triggers, and variables <strong>forbidden</strong> on a page. Enter one class per line.'),
+      '#default_value' => $container->get("{$this->prefix}blacklist_classes"),
+      '#rows' => 5,
+      '#states' => $this->statesArray('include_classes'),
+    ];
+
+    $fieldset['include_environment'] = [
+      '#type' => 'checkbox',
+      '#title' => $this->t('Include an environment'),
+      '#description' => $this->t('If checked, then the applicable snippets will include the environment items below. Enable <strong>only for development</strong> purposes.'),
+      '#default_value' => $container->get("{$this->prefix}include_environment"),
+    ];
+
+    $description = $this->t('The environment ID to use with this website container. To get an environment ID, <a href="https://tagmanager.google.com/#/admin">select Environments</a>, create an environment, then click the "Get Snippet" action. The environment ID and token will be in the snippet.');
+
+    $fieldset['environment_id'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Environment ID'),
+      '#description' => $description,
+      '#default_value' => $container->get("{$this->prefix}environment_id"),
+      '#attributes' => ['placeholder' => ['env-x']],
+      '#size' => 10,
+      '#maxlength' => 7,
+      '#states' => $this->statesArray('include_environment'),
+    ];
+
+    $fieldset['environment_token'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Environment token'),
+      '#description' => $this->t('The authentication token for this environment.'),
+      '#default_value' => $container->get("{$this->prefix}environment_token"),
+      '#attributes' => ['placeholder' => ['xxxxxxxxxxxxxxxxxxxxxx']],
+      '#size' => 20,
+      '#maxlength' => 25,
+      '#states' => $this->statesArray('include_environment'),
+    ];
+
+    return $fieldset;
+  }
+
+  /**
+   * Returns states array for a form element.
+   *
+   * @param string $variable
+   *   The name of the form element.
+   *
+   * @return array
+   *   The states array.
+   */
+  public function statesArray($variable) {
+    return [
+      'required' => [
+        ':input[name="' . $variable . '"]' => ['checked' => TRUE],
+      ],
+      'invisible' => [
+        ':input[name="' . $variable . '"]' => ['checked' => FALSE],
+      ],
+    ];
+  }
+
+  /**
+   * Fieldset builder for the container settings form.
+   */
+  public function pathFieldset(FormStateInterface &$form_state) {
+    $fieldset_title = $this->t('Request path');
+    $fieldset_description = $this->t('On this and the following tabs, specify the conditions on which the GTM JavaScript snippet will either be inserted on or omitted from the page response, thereby enabling or disabling tracking and other analytics. All conditions must be satisfied for the snippet to be inserted. The snippet will be omitted if any condition is not met.');
+    $args = array(
+      '%node' => '/node',
+      '%user-wildcard' => '/user/*',
+      '%front' => '<front>',
+    );
+    $description = $this->t('Enter one relative path per line using the "*" character as a wildcard. Example paths are: "%node" for the node page, "%user-wildcard" for each individual user, and "%front" for the front page.', $args);
+    $rows = 10;
+    $singular = 'path';
+    $plural = 'paths';
+    $adjective = 'listed';
+    $config = compact(array('fieldset_title', 'fieldset_description', 'singular', 'plural', 'adjective', 'description', 'rows'));
+    return $this->genericFieldset($config, $form_state);
+  }
+
+  /**
+   * Fieldset builder for the container settings form.
+   */
+  public function roleFieldset(FormStateInterface &$form_state) {
+    $fieldset_title = $this->t('User role');
+    $singular = 'role';
+    $plural = 'roles';
+    $options = array_map(function ($role) {
+      return $role->label();
+    }, user_roles());
+    $config = compact(array('fieldset_title', 'singular', 'plural', 'options'));
+    return $this->genericFieldset($config, $form_state);
+  }
+
+  /**
+   * Fieldset builder for the container settings form.
+   */
+  public function statusFieldset(FormStateInterface &$form_state) {
+    $fieldset_title = $this->t('Response status');
+    $description = $this->t('Enter one response status per line. For more information, refer to the <a href="http://en.wikipedia.org/wiki/List_of_HTTP_status_codes">list of HTTP status codes</a>.');
+    $rows = 5;
+    $singular = 'status';
+    $plural = 'statuses';
+    $adjective = 'listed';
+    $config = compact(array('fieldset_title', 'singular', 'plural', 'adjective', 'description', 'rows'));
+    return $this->genericFieldset($config, $form_state);
+  }
+
+  /**
+   * Fieldset builder for the container settings form.
+   */
+  public function genericFieldset(array $config, FormStateInterface &$form_state) {
+    $container = $this->container;
+
+    // Gather data.
+    $config += array('fieldset_description' => '', 'adjective' => 'selected');
+    extract($config);
+    $toggle = "{$singular}_toggle";
+    $list = "{$singular}_list";
+    $args = array(
+      '@adjective' => $adjective,
+      '@uc_adjective' => ucfirst($adjective),
+      '@plural' => $plural,
+    );
+
+    // Build form elements.
+    $fieldset = [
+      '#type' => 'details',
+      '#title' => $fieldset_title,
+      '#description' => $fieldset_description,
+      '#group' => 'conditions',
+    ];
+
+    $fieldset[$toggle] = [
+      '#type' => 'radios',
+      '#title' => $this->specialT('Insert snippet for specific @plural', $args),
+      '#options' => [
+        GOOGLE_TAG_EXCLUDE_LISTED => $this->specialT('All @plural except the @adjective @plural', $args),
+        GOOGLE_TAG_INCLUDE_LISTED => $this->specialT('Only the @adjective @plural', $args),
+      ],
+      '#default_value' => $container->get("{$this->prefix}$toggle"),
+    ];
+
+    if ($adjective == 'selected') {
+      $fieldset[$list] = [
+        '#type' => 'checkboxes',
+        '#title' => $this->specialT('@uc_adjective @plural', $args),
+        '#options' => $options,
+        '#default_value' => $container->get("{$this->prefix}$list"),
+      ];
+    }
+    else {
+      $fieldset[$list] = [
+        '#type' => 'textarea',
+        '#title' => $this->specialT('@uc_adjective @plural', $args),
+        '#description' => $description,
+        '#default_value' => $container->get("{$this->prefix}$list"),
+        '#rows' => $rows,
+      ];
+    }
+
+    return $fieldset;
+  }
+
+  /**
+   * Returns a translated string after placeholder substitution.
+   *
+   * @param string $string
+   *   The string to manipulate.
+   * @param array $args
+   *   The associative array of replacement values.
+   *
+   * @return string
+   *   The translated string.
+   */
+  protected function specialT($string, array $args) {
+    return $this->t(strtr($string, $args));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateFormValues(array &$form, FormStateInterface $form_state) {
+    // Specific to the container form.
+    $container_id = $form_state->getValue('container_id');
+    if (!is_null($container_id)) {
+      $container_id = trim($container_id);
+      $container_id = str_replace(['–', '—', '−'], '-', $container_id);
+      $form_state->setValue('container_id', $container_id);
+
+      if (!preg_match('/^GTM-\w{4,}$/', $container_id)) {
+        // @todo Is there a more specific regular expression that applies?
+        // @todo Is there a way to validate the container ID?
+        // It may be valid but not the correct one for the website.
+        $form_state->setError($form['general']['container_id'], $this->t('A valid container ID is case sensitive and formatted like GTM-xxxxxx.'));
+      }
+    }
+
+    // Specific to the settings form.
+    $uri = $form_state->getValue('uri');
+    if (!is_null($uri)) {
+      $uri = trim($uri);
+      $form_state->setValue('uri', $uri);
+
+      $directory = $uri;
+      if (substr($directory, -3) == '://') {
+        $args = ['%directory' => $directory];
+        $message = 'The snippet parent uri %directory is invalid. Enter a single trailing slash to specify a plain stream wrapper.';
+        $form_state->setError($form['settings']['uri'], $this->t($message, $args));
+      }
+
+      // Allow for a plain stream wrapper with one trailing slash.
+      $directory .= substr($directory, -2) == ':/' ? '/' : '';
+      if (!is_dir($directory) || !_google_tag_is_writable($directory) || !_google_tag_is_executable($directory)) {
+        $args = ['%directory' => $directory];
+        $message = 'The snippet parent uri %directory is invalid, possibly due to file system permissions. The directory either does not exist, or is not writable or searchable.';
+        $form_state->setError($form['settings']['uri'], $this->t($message, $args));
+      }
+    }
+
+    // Trim the text values.
+    $environment_id = trim($form_state->getValue('environment_id'));
+    $form_state->setValue('data_layer', trim($form_state->getValue('data_layer')));
+    $form_state->setValue('path_list', $this->cleanText($form_state->getValue('path_list')));
+    $form_state->setValue('status_list', $this->cleanText($form_state->getValue('status_list')));
+    $form_state->setValue('whitelist_classes', $this->cleanText($form_state->getValue('whitelist_classes')));
+    $form_state->setValue('blacklist_classes', $this->cleanText($form_state->getValue('blacklist_classes')));
+
+    // Replace all types of dashes (n-dash, m-dash, minus) with a normal dash.
+    $environment_id = str_replace(['–', '—', '−'], '-', $environment_id);
+    $form_state->setValue('environment_id', $environment_id);
+
+    $form_state->setValue('role_list', array_filter($form_state->getValue('role_list')));
+
+    if ($form_state->getValue('include_environment') && !preg_match('/^env-\d{1,}$/', $environment_id)) {
+      $form_state->setError($form['advanced']['environment_id'], $this->t('A valid environment ID is case sensitive and formatted like env-x.'));
+    }
+  }
+
+  /**
+   * Cleans a string representing a list of items.
+   *
+   * @param string $text
+   *   The string to clean.
+   * @param string $format
+   *   The final format of $text, either 'string' or 'array'.
+   *
+   * @return string
+   *   The clean text.
+   */
+  public function cleanText($text, $format = 'string') {
+    $text = explode("\n", $text);
+    $text = array_map('trim', $text);
+    $text = array_filter($text, 'trim');
+    if ($format == 'string') {
+      $text = implode("\n", $text);
+    }
+    return $text;
+  }
+
+}
diff --git a/web/modules/google_tag/src/Form/GoogleTagSettingsForm.php b/web/modules/google_tag/src/Form/GoogleTagSettingsForm.php
deleted file mode 100644
index 1056b8ba0437fe64c7cd2ace002dd07ce7d283f3..0000000000000000000000000000000000000000
--- a/web/modules/google_tag/src/Form/GoogleTagSettingsForm.php
+++ /dev/null
@@ -1,432 +0,0 @@
-<?php
-
-/**
- * @author Jim Berry ("solotandem", http://drupal.org/user/240748)
- */
-
-namespace Drupal\google_tag\Form;
-
-use Drupal\Core\Form\ConfigFormBase;
-use Drupal\Core\Form\FormStateInterface;
-
-/**
- * Class GoogleTagSettingsForm
- * @package Drupal\google_tag\Form
- */
-class GoogleTagSettingsForm extends ConfigFormBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  public function getFormId() {
-    return 'google_tag_settings';
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function getEditableConfigNames() {
-    return ['google_tag.settings'];
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function buildForm(array $form, FormStateInterface $form_state) {
-    $config = $this->config('google_tag.settings');
-
-    // Build form elements.
-    $form['settings'] = [
-      '#type' => 'vertical_tabs',
-      '#attributes' => ['class' => ['google-tag']],
-      '#attached' => [
-        'library' => ['google_tag/drupal.settings_form'],
-      ],
-    ];
-
-    // General tab.
-    $form['general'] = [
-      '#type' => 'details',
-      '#title' => $this->t('General'),
-      '#group' => 'settings',
-    ];
-
-    $form['general']['container_id'] = [
-      '#type' => 'textfield',
-      '#title' => $this->t('Container ID'),
-      '#description' => $this->t('The ID assigned by Google Tag Manager (GTM) for this website container. To get a container ID, <a href="https://tagmanager.google.com/">sign up for GTM</a> and create a container for your website.'),
-      '#default_value' => $config->get('container_id'),
-      '#attributes' => ['placeholder' => ['GTM-xxxxxx']],
-      '#size' => 12,
-      '#maxlength' => 15,
-      '#required' => TRUE,
-    ];
-
-    // Page paths tab.
-    $description = $this->t('On this and the next two tabs, specify the conditions on which the GTM JavaScript snippet will either be included in or excluded from the page response, thereby enabling or disabling tracking and other analytics. All conditions must be satisfied for the snippet to be included. The snippet will be excluded if any condition is not met.<br /><br />On this tab, specify the path condition.');
-
-    // @todo Use singular for element names.
-    $form['path'] = [
-      '#type' => 'details',
-      '#title' => $this->t('Page paths'),
-      '#group' => 'settings',
-      '#description' => $description,
-    ];
-
-    $form['path']['path_toggle'] = [
-      '#type' => 'radios',
-      '#title' => $this->t('Add snippet on specific paths'),
-      '#options' => [
-        GOOGLE_TAG_EXCLUDE_LISTED => $this->t('All paths except the listed paths'),
-        GOOGLE_TAG_INCLUDE_LISTED => $this->t('Only the listed paths'),
-      ],
-      '#default_value' => $config->get('path_toggle'),
-    ];
-
-    $args = [
-      '%blog' => '/blog',
-      '%blog-wildcard' => '/blog/*',
-      '%front' => '<front>',
-    ];
-
-    $form['path']['path_list'] = [
-      '#type' => 'textarea',
-      '#title' => $this->t('Listed paths'),
-      '#description' => $this->t('Enter one relative path per line using the "*" character as a wildcard. Example paths are: "%blog" for the blog page, "%blog-wildcard" for each individual blog, and "%front" for the front page.', $args),
-      '#default_value' => $config->get('path_list'),
-      '#rows' => 10,
-    ];
-
-    // User roles tab.
-    $form['role'] = [
-      '#type' => 'details',
-      '#title' => $this->t('User roles'),
-      '#description' => $this->t('On this tab, specify the user role condition.'),
-      '#group' => 'settings',
-    ];
-
-    $form['role']['role_toggle'] = [
-      '#type' => 'radios',
-      '#title' => $this->t('Add snippet for specific roles'),
-      '#options' => [
-        GOOGLE_TAG_EXCLUDE_LISTED => $this->t('All roles except the selected roles'),
-        GOOGLE_TAG_INCLUDE_LISTED => $this->t('Only the selected roles'),
-      ],
-      '#default_value' => $config->get('role_toggle'),
-    ];
-
-    $user_roles = array_map(function($role) {
-      return $role->label();
-    }, user_roles());
-
-    $form['role']['role_list'] = [
-      '#type' => 'checkboxes',
-      '#title' => $this->t('Selected roles'),
-      '#default_value' => $config->get('role_list'),
-      '#options' => $user_roles,
-    ];
-
-    // Response statuses tab.
-    $description = $this->t('Enter one response status per line. For more information, refer to the <a href="http://en.wikipedia.org/wiki/List_of_HTTP_status_codes">list of HTTP status codes</a>.');
-
-    $form['status'] = [
-      '#type' => 'details',
-      '#title' => $this->t('Response statuses'),
-      '#group' => 'settings',
-      '#description' => $this->t('On this tab, specify the page response status condition.'),
-    ];
-
-    $form['status']['status_toggle'] = [
-      '#type' => 'radios',
-      '#title' => $this->t('Add snippet for specific statuses'),
-      '#options' => [
-        GOOGLE_TAG_EXCLUDE_LISTED => $this->t('All statuses except the listed statuses'),
-        GOOGLE_TAG_INCLUDE_LISTED => $this->t('Only the listed statuses'),
-      ],
-      '#default_value' => $config->get('status_toggle'),
-    ];
-
-    $form['status']['status_list'] = [
-      '#type' => 'textarea',
-      '#title' => $this->t('Listed statuses'),
-      '#description' => $description,
-      '#default_value' => $config->get('status_list'),
-      '#rows' => 5,
-    ];
-
-    // Advanced tab.
-    $form['advanced'] = [
-      '#type' => 'details',
-      '#title' => $this->t('Advanced'),
-      '#group' => 'settings',
-    ];
-
-    $form['advanced']['compact_snippet'] = [
-      '#type' => 'checkbox',
-      '#title' => $this->t('Compact the JavaScript snippet'),
-      '#description' => $this->t('If checked, then the JavaScript snippet will be compacted to remove unnecessary whitespace. This is <strong>recommended on production sites</strong>. Leave unchecked to output a snippet that can be examined using a JavaScript debugger in the browser.'),
-      '#default_value' => $config->get('compact_snippet'),
-    ];
-
-    $form['advanced']['include_file'] = [
-      '#type' => 'checkbox',
-      '#title' => $this->t('Include the snippet as a file'),
-      '#description' => $this->t('If checked, then each JavaScript snippet will be included as a file. This is <strong>recommended</strong>. Leave unchecked to inline each snippet into the page. This only applies to data layer and script snippets.'),
-      '#default_value' => $config->get('include_file'),
-    ];
-
-    $form['advanced']['rebuild_snippets'] = [
-      '#type' => 'checkbox',
-      '#title' => $this->t('Recreate snippets on cache rebuild'),
-      '#description' => $this->t('If checked, then the JavaScript snippet files will be created during a cache rebuild. This is <strong>recommended on production sites</strong>.'),
-      '#default_value' => $config->get('rebuild_snippets'),
-    ];
-
-    $form['advanced']['debug_output'] = [
-      '#type' => 'checkbox',
-      '#title' => $this->t('Show debug output'),
-      '#description' => $this->t('If checked, then the result of each snippet insertion condition will be shown in the message area. Enable <strong>only for development</strong> purposes.'),
-      '#default_value' => $config->get('debug_output'),
-    ];
-
-    $form['advanced']['data_layer'] = [
-      '#type' => 'textfield',
-      '#title' => $this->t('Data layer'),
-      '#description' => $this->t('The name of the data layer. Default value is "dataLayer". In most cases, use the default.'),
-      '#default_value' => $config->get('data_layer'),
-      '#attributes' => ['placeholder' => ['dataLayer']],
-      '#required' => TRUE,
-    ];
-
-    $form['advanced']['include_classes'] = [
-      '#type' => 'checkbox',
-      '#title' => $this->t('Add classes to the data layer'),
-      '#description' => $this->t('If checked, then the listed classes will be added to the data layer.'),
-      '#default_value' => $config->get('include_classes'),
-    ];
-
-    $description = $this->t('The types of tags, triggers, and variables <strong>allowed</strong> on a page. Enter one class per line. For more information, refer to the <a href="https://developers.google.com/tag-manager/devguide#security">developer documentation</a>.');
-
-    $form['advanced']['whitelist_classes'] = [
-      '#type' => 'textarea',
-      '#title' => $this->t('White-listed classes'),
-      '#description' => $description,
-      '#default_value' => $config->get('whitelist_classes'),
-      '#rows' => 5,
-      '#states' => $this->statesArray('include_classes'),
-    ];
-
-    $form['advanced']['blacklist_classes'] = [
-      '#type' => 'textarea',
-      '#title' => $this->t('Black-listed classes'),
-      '#description' => $this->t('The types of tags, triggers, and variables <strong>forbidden</strong> on a page. Enter one class per line.'),
-      '#default_value' => $config->get('blacklist_classes'),
-      '#rows' => 5,
-      '#states' => $this->statesArray('include_classes'),
-    ];
-
-    $form['advanced']['include_environment'] = [
-      '#type' => 'checkbox',
-      '#title' => $this->t('Include an environment'),
-      '#description' => $this->t('If checked, then the applicable snippets will include the environment items below. Enable <strong>only for development</strong> purposes.'),
-      '#default_value' => $config->get('include_environment'),
-    ];
-
-    $description = $this->t('The environment ID to use with this website container. To get an environment ID, <a href="https://tagmanager.google.com/#/admin">select Environments</a>, create an environment, then click the "Get Snippet" action. The environment ID and token will be in the snippet.');
-
-    $form['advanced']['environment_id'] = [
-      '#type' => 'textfield',
-      '#title' => $this->t('Environment ID'),
-      '#description' => $description,
-      '#default_value' => $config->get('environment_id'),
-      '#attributes' => ['placeholder' => ['env-x']],
-      '#size' => 10,
-      '#maxlength' => 7,
-      '#states' => $this->statesArray('include_environment'),
-    ];
-
-    $form['advanced']['environment_token'] = [
-      '#type' => 'textfield',
-      '#title' => $this->t('Environment token'),
-      '#description' => $this->t('The authentication token for this environment.'),
-      '#default_value' => $config->get('environment_token'),
-      '#attributes' => ['placeholder' => ['xxxxxxxxxxxxxxxxxxxxxx']],
-      '#size' => 20,
-      '#maxlength' => 25,
-      '#states' => $this->statesArray('include_environment'),
-    ];
-
-    return parent::buildForm($form, $form_state);
-  }
-
-  /**
-   * Returns states array for a form element.
-   *
-   * @param string $variable
-   *   The name of the form element.
-   *
-   * @return array
-   *   The states array.
-   */
-  public function statesArray($variable) {
-    return [
-      'required' => [
-        ':input[name="' . $variable . '"]' => ['checked' => TRUE],
-      ],
-      'invisible' => [
-        ':input[name="' . $variable . '"]' => ['checked' => FALSE],
-      ],
-    ];
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function validateForm(array &$form, FormStateInterface $form_state) {
-    // Trim the text values.
-    $container_id = trim($form_state->getValue('container_id'));
-    $environment_id = trim($form_state->getValue('environment_id'));
-    $form_state->setValue('data_layer', trim($form_state->getValue('data_layer')));
-    $form_state->setValue('path_list', $this->cleanText($form_state->getValue('path_list')));
-    $form_state->setValue('status_list', $this->cleanText($form_state->getValue('status_list')));
-    $form_state->setValue('whitelist_classes', $this->cleanText($form_state->getValue('whitelist_classes')));
-    $form_state->setValue('blacklist_classes', $this->cleanText($form_state->getValue('blacklist_classes')));
-
-    // Replace all types of dashes (n-dash, m-dash, minus) with a normal dash.
-    $container_id = str_replace(['–', '—', '−'], '-', $container_id);
-    $environment_id = str_replace(['–', '—', '−'], '-', $environment_id);
-    $form_state->setValue('container_id', $container_id);
-    $form_state->setValue('environment_id', $environment_id);
-
-    if (!preg_match('/^GTM-\w{4,}$/', $container_id)) {
-      // @todo Is there a more specific regular expression that applies?
-      // @todo Is there a way to "test the connection" to determine a valid ID for
-      // a container? It may be valid but not the correct one for the website.
-      $form_state->setError($form['general']['container_id'], $this->t('A valid container ID is case sensitive and formatted like GTM-xxxxxx.'));
-    }
-    if ($form_state->getValue('include_environment') && !preg_match('/^env-\d{1,}$/', $environment_id)) {
-      $form_state->setError($form['advanced']['environment_id'], $this->t('A valid environment ID is case sensitive and formatted like env-x.'));
-    }
-
-    parent::validateForm($form, $form_state);
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function submitForm(array &$form, FormStateInterface $form_state) {
-    $this->config('google_tag.settings')
-      ->set('container_id', $form_state->getValue('container_id'))
-      ->set('path_toggle', $form_state->getValue('path_toggle'))
-      ->set('path_list', $form_state->getValue('path_list'))
-      ->set('role_toggle', $form_state->getValue('role_toggle'))
-      ->set('role_list', $form_state->getValue('role_list'))
-      ->set('status_toggle', $form_state->getValue('status_toggle'))
-      ->set('status_list', $form_state->getValue('status_list'))
-      ->set('compact_snippet', $form_state->getValue('compact_snippet'))
-      ->set('include_file', $form_state->getValue('include_file'))
-      ->set('rebuild_snippets', $form_state->getValue('rebuild_snippets'))
-      ->set('debug_output', $form_state->getValue('debug_output'))
-      ->set('data_layer', $form_state->getValue('data_layer'))
-      ->set('include_classes', $form_state->getValue('include_classes'))
-      ->set('whitelist_classes', $form_state->getValue('whitelist_classes'))
-      ->set('blacklist_classes', $form_state->getValue('blacklist_classes'))
-      ->set('include_environment', $form_state->getValue('include_environment'))
-      ->set('environment_id', $form_state->getValue('environment_id'))
-      ->set('environment_token', $form_state->getValue('environment_token'))
-      ->save();
-
-    parent::submitForm($form, $form_state);
-
-    global $google_tag_display_message;
-    $google_tag_display_message = TRUE;
-    $this->createAssets();
-  }
-
-  /**
-   * Prepares directory for and saves snippet files based on current settings.
-   *
-   * @return bool
-   *   Whether the files were saved.
-   */
-  public function createAssets() {
-    $result = TRUE;
-    $directory = 'public://google_tag';
-    if (!is_dir($directory) || !_google_tag_is_writable($directory) || !_google_tag_is_executable($directory)) {
-      $result = __file_prepare_directory($directory, FILE_CREATE_DIRECTORY | FILE_MODIFY_PERMISSIONS);
-    }
-    if ($result) {
-      $result = $this->saveSnippets();
-    }
-    else {
-      $args = ['%directory' => $directory];
-      $message = 'The directory %directory could not be prepared for use, possibly due to file system permissions. The directory either does not exist, or is not writable or searchable.';
-      $this->displayMessage($message, $args, 'error');
-      \Drupal::logger('google_tag')->error($message, $args);
-    }
-    return $result;
-  }
-
-  /**
-   * Saves JS snippet files based on current settings.
-   *
-   * @return bool
-   *   Whether the files were saved.
-   */
-  public function saveSnippets() {
-    // Save the altered snippets after hook_google_tag_snippets_alter().
-    module_load_include('inc', 'google_tag', 'includes/snippet');
-    $result = TRUE;
-    $snippets = google_tag_snippets();
-    foreach ($snippets as $type => $snippet) {
-      $path = file_unmanaged_save_data($snippet, "public://google_tag/google_tag.$type.js", FILE_EXISTS_REPLACE);
-      $result = !$path ? FALSE : $result;
-    }
-    $args = ['@count' => count($snippets)];
-    if (!$result) {
-      $message = 'An error occurred saving @count snippet files. Contact the site administrator if this persists.';
-      $this->displayMessage($message, $args, 'error');
-      \Drupal::logger('google_tag')->error($message, $args);
-    }
-    else {
-      $message = 'Created @count snippet files based on configuration.';
-      $this->displayMessage($message, $args);
-      \Drupal::service('asset.js.collection_optimizer')->deleteAll();
-      _drupal_flush_css_js();
-    }
-    return $result;
-  }
-
-  /**
-   * Cleans a string representing a list of items.
-   *
-   * @param string $text
-   *   The string to clean.
-   * @param string $format
-   *   The final format of $text, either 'string' or 'array'.
-   *
-   * @return string
-   *   The clean text.
-   */
-  public function cleanText($text, $format = 'string') {
-    $text = explode("\n", $text);
-    $text = array_map('trim', $text);
-    $text = array_filter($text, 'trim');
-    if ($format == 'string') {
-      $text = implode("\n", $text);
-    }
-    return $text;
-  }
-
-  /**
-   * Displays a message to admin users.
-   *
-   * @see arguments to t() and drupal_set_message()
-   */
-  public function displayMessage($message, $args = [], $type = 'status') {
-    global $google_tag_display_message;
-    if ($google_tag_display_message) {
-      drupal_set_message($this->t($message, $args), $type);
-    }
-  }
-}
diff --git a/web/modules/google_tag/src/Form/SettingsForm.php b/web/modules/google_tag/src/Form/SettingsForm.php
new file mode 100644
index 0000000000000000000000000000000000000000..da521ae9743ffc1bbe61bf013ce8eb05eac8fff8
--- /dev/null
+++ b/web/modules/google_tag/src/Form/SettingsForm.php
@@ -0,0 +1,176 @@
+<?php
+
+namespace Drupal\google_tag\Form;
+
+use Drupal\Core\Form\ConfigFormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Url;
+
+/**
+ * Defines the Google tag manager module and default container settings form.
+ */
+class SettingsForm extends ConfigFormBase {
+
+  use ContainerTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'google_tag_settings';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getEditableConfigNames() {
+    return ['google_tag.settings'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    $this->container = $this->config('google_tag.settings');
+    $this->prefix = '_default_container.';
+
+    // Build form elements.
+    $description = $this->t('<br />After configuring the module settings and default properties for a new container, <strong>add a container</strong> on the <a href=":url">container management page</a>.', array(':url' => Url::fromRoute('entity.google_tag_container.collection')->toString()));
+
+    $form['instruction'] = [
+      '#type' => 'markup',
+      '#markup' => $description,
+    ];
+
+    $form['module'] = $this->moduleFieldset($form_state);
+
+    $form['settings'] = [
+      '#type' => 'vertical_tabs',
+      '#title' => $this->t('Default container settings'),
+      '#description' => $this->t('The default container settings that apply to a new container.'),
+      '#attributes' => ['class' => ['google-tag']],
+    ];
+
+    $form['conditions'] = [
+      '#type' => 'vertical_tabs',
+      '#title' => $this->t('Default insertion conditions'),
+      '#description' => $this->t('The default snippet insertion conditions that apply to a new container.'),
+      '#attributes' => ['class' => ['google-tag']],
+      '#attached' => [
+        'library' => ['google_tag/drupal.settings_form'],
+      ],
+    ];
+
+    $form['advanced'] = $this->advancedFieldset($form_state);
+    $form['path'] = $this->pathFieldset($form_state);
+    $form['role'] = $this->roleFieldset($form_state);
+    $form['status'] = $this->statusFieldset($form_state);
+
+    return parent::buildForm($form, $form_state);
+  }
+
+  /**
+   * Fieldset builder for the module settings form.
+   */
+  public function moduleFieldset(FormStateInterface $form_state) {
+    $config = $this->config('google_tag.settings');
+
+    // Build form elements.
+    $fieldset = [
+      '#type' => 'fieldset',
+      '#title' => $this->t('Module settings'),
+      '#description' => $this->t('The settings that apply to all containers.'),
+      '#collapsible' => TRUE,
+      '#collapsed' => FALSE,
+    ];
+
+    $fieldset['uri'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Snippet parent URI'),
+      '#description' => $this->t('The parent URI for saving snippet files. Snippet files will be saved to "[uri]/google_tag". Enter a plain stream wrapper with a single trailing slash like "public:/".'),
+      '#default_value' => $config->get('uri'),
+      '#attributes' => ['placeholder' => ['public:/']],
+      '#required' => TRUE,
+    ];
+
+    $fieldset['compact_snippet'] = [
+      '#type' => 'checkbox',
+      '#title' => $this->t('Compact the JavaScript snippet'),
+      '#description' => $this->t('If checked, then the JavaScript snippet will be compacted to remove unnecessary whitespace. This is <strong>recommended on production sites</strong>. Leave unchecked to output a snippet that can be examined using a JavaScript debugger in the browser.'),
+      '#default_value' => $config->get('compact_snippet'),
+    ];
+
+    $fieldset['include_file'] = [
+      '#type' => 'checkbox',
+      '#title' => $this->t('Include the snippet as a file'),
+      '#description' => $this->t('If checked, then each JavaScript snippet will be included as a file. This is <strong>recommended</strong>. Leave unchecked to inline each snippet into the page. This only applies to data layer and script snippets.'),
+      '#default_value' => $config->get('include_file'),
+    ];
+
+    $fieldset['rebuild_snippets'] = [
+      '#type' => 'checkbox',
+      '#title' => $this->t('Recreate snippets on cache rebuild'),
+      '#description' => $this->t('If checked, then the JavaScript snippet files will be created during a cache rebuild. This is <strong>recommended on production sites</strong>.'),
+      '#default_value' => $config->get('rebuild_snippets'),
+    ];
+
+    $fieldset['flush_snippets'] = [
+      '#type' => 'checkbox',
+      '#title' => $this->t('Flush snippet directory on cache rebuild'),
+      '#description' => $this->t('If checked, then the snippet directory will be deleted and recreated during a cache rebuild. If not checked, then manual intervention may be required to tidy up the snippet directory (e.g. remove snippet files for a deleted container).'),
+      '#default_value' => $config->get('flush_snippets'),
+    ];
+
+    $fieldset['debug_output'] = [
+      '#type' => 'checkbox',
+      '#title' => $this->t('Show debug output'),
+      '#description' => $this->t('If checked, then the result of each snippet insertion condition will be shown in the message area. Enable <strong>only for development</strong> purposes.'),
+      '#default_value' => $config->get('debug_output'),
+    ];
+
+    return $fieldset;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateForm(array &$form, FormStateInterface $form_state) {
+    $this->validateFormValues($form, $form_state);
+    parent::validateForm($form, $form_state);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    $config = $this->config('google_tag.settings');
+    $old_uri = $config->get('uri');
+
+    $settings = $config->get();
+    unset($settings['_default_container'], $settings['_core']);
+    foreach (array_keys($settings) as $key) {
+      $config->set($key, $form_state->getValue($key));
+    }
+    $default_container = $config->get('_default_container');
+    unset($default_container['container_id']);
+    foreach (array_keys($default_container) as $key) {
+      $config->set("_default_container.$key", $form_state->getValue($key));
+    }
+    $config->save();
+
+    parent::submitForm($form, $form_state);
+
+    $new_uri = $config->get('uri');
+    if ($old_uri != $new_uri) {
+      // The snippet uri changed; recreate snippets for all containers.
+      global $_google_tag_display_message;
+      $_google_tag_display_message = TRUE;
+      _google_tag_assets_create();
+
+      $message = 'The snippet directory was changed and the snippet files were created in the new directory. The old directory at %directory was not deleted.';
+      $args = ['%directory' => $old_uri . '/google_tag'];
+      $this->messenger()->addWarning($this->t($message, $args));
+    }
+  }
+
+}
diff --git a/web/modules/google_tag/src/Plugin/Condition/Domain.php b/web/modules/google_tag/src/Plugin/Condition/Domain.php
new file mode 100644
index 0000000000000000000000000000000000000000..4ae6c4873e9adbba80ffe8555d21acc2af45e7d9
--- /dev/null
+++ b/web/modules/google_tag/src/Plugin/Condition/Domain.php
@@ -0,0 +1,114 @@
+<?php
+
+namespace Drupal\google_tag\Plugin\Condition;
+
+use Drupal\google_tag\ConditionBase;
+use Drupal\domain\DomainNegotiator;
+use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides a 'Domain' condition.
+ *
+ * @Condition(
+ *   id = "gtag_domain",
+ *   label = @Translation("Domain"),
+ *   context = {
+ *     "entity:domain" = @ContextDefinition("entity:domain", label = @Translation("Domain"), required = TRUE)
+ *   }
+ * )
+ */
+class Domain extends ConditionBase implements ContainerFactoryPluginInterface {
+
+  /**
+   * The domain negotiator.
+   *
+   * @var \Drupal\domain\DomainNegotiator
+   */
+  protected $domainNegotiator;
+
+  /**
+   * Constructs a domain condition plugin.
+   *
+   * @param \Drupal\domain\DomainNegotiator $domain_negotiator
+   *   The domain negotiator service.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   * @param array $configuration
+   *   A configuration array containing information about the plugin instance.
+   * @param string $plugin_id
+   *   The plugin_id for the plugin instance.
+   * @param mixed $plugin_definition
+   *   The plugin implementation definition.
+   */
+  public function __construct(DomainNegotiator $domain_negotiator, EntityStorageInterface $storage_manager, array $configuration, $plugin_id, $plugin_definition) {
+    $this->toggle = 'domain_toggle';
+    $this->list = 'domain_list';
+    $this->singular = 'domain';
+    $this->plural = 'domains';
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+    $this->domainNegotiator = $domain_negotiator;
+    $this->options = array_map('\Drupal\Component\Utility\Html::escape', $storage_manager->loadOptionsList());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    return new static(
+      $container->get('domain.negotiator'),
+      $container->get('entity_type.manager')->getStorage('domain'),
+      $configuration,
+      $plugin_id,
+      $plugin_definition
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
+    $form = parent::buildConfigurationForm($form, $form_state);
+    if (isset($form['context_mapping']['entity:domain']['#title'])) {
+      $form['context_mapping']['entity:domain']['#title'] = $this->t('Select the Domain context');
+      $form['context_mapping']['entity:domain']['#description'] = $this->t('This value must be set to "Active domain" for the context to work.');
+    }
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function summary() {
+    $this->values = array_intersect_key($this->options, $this->configuration['domain_list']);
+    return parent::summary();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    $contexts = parent::getCacheContexts();
+    $contexts[] = 'url.site';
+    return $contexts;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function contextToEvaluate() {
+    $domain = $this->getContextValue('entity:domain');
+    // @todo Is this checking necessary? Does it reflect brittleness by domain?
+    if (!$domain) {
+      // The context did not load; try to derive it from the request.
+      $domain = $this->domainNegotiator->getActiveDomain();
+    }
+    if (empty($domain)) {
+      return FALSE;
+    }
+    return $domain->id();
+  }
+
+}
diff --git a/web/modules/google_tag/src/Plugin/Condition/Language.php b/web/modules/google_tag/src/Plugin/Condition/Language.php
new file mode 100644
index 0000000000000000000000000000000000000000..ef99d1bf4bd1ae1ba3070d6909b35b898345a25a
--- /dev/null
+++ b/web/modules/google_tag/src/Plugin/Condition/Language.php
@@ -0,0 +1,123 @@
+<?php
+
+namespace Drupal\google_tag\Plugin\Condition;
+
+use Drupal\google_tag\ConditionBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Language\LanguageInterface;
+use Drupal\Core\Language\LanguageManagerInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides a 'Language' condition.
+ *
+ * @Condition(
+ *   id = "gtag_language",
+ *   label = @Translation("Language"),
+ *   context_definitions = {
+ *     "language" = @ContextDefinition("language", label = @Translation("Language"))
+ *   }
+ * )
+ */
+class Language extends ConditionBase implements ContainerFactoryPluginInterface {
+
+  /**
+   * The Language manager.
+   *
+   * @var \Drupal\Core\Language\LanguageManagerInterface
+   */
+  protected $languageManager;
+
+  /**
+   * Constructs a language condition plugin.
+   *
+   * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
+   *   The language manager.
+   * @param array $configuration
+   *   The plugin configuration, i.e. an array with configuration values keyed
+   *   by configuration option name. The special key 'context' may be used to
+   *   initialize the defined contexts by setting it to an array of context
+   *   values keyed by context names.
+   * @param string $plugin_id
+   *   The plugin_id for the plugin instance.
+   * @param mixed $plugin_definition
+   *   The plugin implementation definition.
+   */
+  public function __construct(LanguageManagerInterface $language_manager, array $configuration, $plugin_id, $plugin_definition) {
+    $this->toggle = 'language_toggle';
+    $this->list = 'language_list';
+    $this->singular = 'language';
+    $this->plural = 'languages';
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+    $this->languageManager = $language_manager;
+    $this->options = $this->languageOptions();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    return new static(
+      $container->get('language_manager'),
+      $configuration,
+      $plugin_id,
+      $plugin_definition
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
+    $form = parent::buildConfigurationForm($form, $form_state);
+    if (!$this->languageManager->isMultilingual()) {
+      $form['language_list'] = [
+        '#type' => 'value',
+        '#default_value' => $this->configuration['language_list'],
+      ];
+    }
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function summary() {
+    $languages = $this->languageManager->getLanguages(LanguageInterface::STATE_ALL);
+    $selected = $this->configuration['language_list'];
+    // Reduce the language object list to a language name list.
+    $this->values = array_reduce($languages, function (&$names, $language) use ($selected) {
+      if (!empty($selected[$language->getId()])) {
+        $names[$language->getId()] = $language->getName();
+      }
+      return $names;
+    }, []);
+    return parent::summary();
+  }
+
+  /**
+   * Returns associative array of language names keyed by language ID.
+   *
+   * @return array
+   *   The associative array of language names keyed by language ID.
+   */
+  public function languageOptions() {
+    $options = [];
+    if ($this->languageManager->isMultilingual()) {
+      $languages = $this->languageManager->getLanguages();
+      foreach ($languages as $language) {
+        $options[$language->getId()] = $language->getName();
+      }
+    }
+    return $options;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function contextToEvaluate() {
+    return $this->getContextValue('language')->getId();
+  }
+
+}
diff --git a/web/modules/google_tag/tests/src/Functional/GTMMultipleTest.php b/web/modules/google_tag/tests/src/Functional/GTMMultipleTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..ce03c5fcbf6c789138488017cd9f2521cc7c5164
--- /dev/null
+++ b/web/modules/google_tag/tests/src/Functional/GTMMultipleTest.php
@@ -0,0 +1,84 @@
+<?php
+
+namespace Drupal\Tests\google_tag\Functional;
+
+/**
+ * Tests the Google Tag Manager for a site with multiple containers.
+ *
+ * @group GoogleTag
+ */
+class GTMMultipleTest extends GTMTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createData() {
+    parent::createData();
+
+    $this->variables['default'] = (object) [
+      'id' => 'default',
+      'label' => 'Default',
+      'weight' => 3,
+      'container_id' => 'GTM-default',
+      'include_environment' => '1',
+      'environment_id' => 'env-7',
+      'environment_token' => 'ddddddddddddddddddddd',
+    ];
+
+    $this->variables['primary'] = (object) [
+      'id' => 'primary',
+      'label' => 'Primary',
+      'weight' => 2,
+      'container_id' => 'GTM-primary',
+      'include_environment' => '1',
+      'environment_id' => 'env-1',
+      'environment_token' => 'ppppppppppppppppppppp',
+    ];
+
+    $this->variables['secondary'] = (object) [
+      'id' => 'secondary',
+      'label' => 'Secondary',
+      'weight' => 1,
+      'container_id' => 'GTM-secondary',
+      'include_environment' => '1',
+      'environment_id' => 'env-2',
+      'environment_token' => 'sssssssssssssssssssss',
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function checkSnippetFiles() {
+    foreach ($this->variables as $key => $variables) {
+      $message = "Start on container $key";
+      parent::assertTrue(TRUE, $message);
+      foreach ($this->types as $type) {
+        $url = "$this->basePath/google_tag/{$key}/google_tag.$type.js";
+        $contents = @file_get_contents($url);
+        $function = "verify{$type}Snippet";
+        $this->$function($contents, $this->variables[$key]);
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function checkPageResponse() {
+    parent::checkPageResponse();
+
+    foreach ($this->variables as $key => $variables) {
+      $this->drupalGet('');
+      $message = "Start on container $key";
+      parent::assertTrue(TRUE, $message);
+      foreach ($this->types as $type) {
+        $uri = "$this->basePath/google_tag/{$key}/google_tag.$type.js";
+        $url = file_url_transform_relative(file_create_url($uri));
+        $function = "verify{$type}Tag";
+        $this->$function($url, $this->variables[$key]);
+      }
+    }
+  }
+
+}
diff --git a/web/modules/google_tag/tests/src/Functional/GTMTestBase.php b/web/modules/google_tag/tests/src/Functional/GTMTestBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..ee3ebf4a359a9d867720ffe32c1709030d0bc204
--- /dev/null
+++ b/web/modules/google_tag/tests/src/Functional/GTMTestBase.php
@@ -0,0 +1,274 @@
+<?php
+
+namespace Drupal\Tests\google_tag\Functional;
+
+use Drupal\google_tag\Entity\Container;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Tests the Google Tag Manager.
+ *
+ * @todo
+ * Use the settings form to save configuration and create snippet files.
+ * Confirm snippet file and page response contents.
+ * Test further the snippet insertion conditions.
+ *
+ * @group GoogleTag
+ */
+abstract class GTMTestBase extends BrowserTestBase {
+
+  /**
+   * Modules to install.
+   *
+   * @var array
+   */
+  protected static $modules = ['google_tag'];
+
+  /**
+   * The snippet file types.
+   *
+   * @var array
+   */
+  protected $types = ['script', 'noscript'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->basePath = \Drupal::config('google_tag.settings')->get('uri');
+  }
+
+  /**
+   * Test the module.
+   */
+  public function testModule() {
+    try {
+      $this->modifySettings();
+      // Create containers in code.
+      $this->createData();
+      $this->saveContainers();
+      $this->checkSnippetFiles();
+      $this->checkPageResponse();
+      // Delete containers.
+      $this->deleteContainers();
+      // Create containers in user interface.
+      $this->submitContainers();
+      $this->checkSnippetFiles();
+      $this->checkPageResponse();
+    }
+    catch (Exception $e) {
+      parent::assertTrue(TRUE, t('Inside CATCH block'));
+      watchdog_exception('gtm_test', $e);
+    }
+    finally {
+      parent::assertTrue(TRUE, t('Inside FINALLY block'));
+    }
+  }
+
+  /**
+   * Modify settings for test purposes.
+   */
+  protected function modifySettings() {
+    // Modify default settings.
+    // These should propagate to each container created in test.
+    $config = \Drupal::service('config.factory')->getEditable('google_tag.settings');
+    $settings = $config->get();
+    unset($settings['_core']);
+    $settings['flush_snippets'] = 1;
+    $settings['debug_output'] = 1;
+    $settings['_default_container']['role_toggle'] = 'include listed';
+    $settings['_default_container']['role_list'] = ['content viewer' => 'content viewer'];
+    $config->setData($settings)->save();
+  }
+
+  /**
+   * Create test data: configuration variables and users.
+   */
+  protected function createData() {
+    // Create an admin user.
+    $this->drupalCreateRole(['access content', 'administer google tag manager'], 'admin user');
+    $this->adminUser = $this->drupalCreateUser();
+    $this->adminUser->roles[] = 'admin user';
+    $this->adminUser->save();
+
+    // Create a test user.
+    $this->drupalCreateRole(['access content'], 'content viewer');
+    $this->nonAdminUser = $this->drupalCreateUser();
+    $this->nonAdminUser->roles[] = 'content viewer';
+    $this->nonAdminUser->save();
+  }
+
+  /**
+   * Save containers in the database and create snippet files.
+   */
+  protected function saveContainers() {
+    foreach ($this->variables as $key => $variables) {
+      // Create container with default container settings, then modify.
+      $container = new Container([], 'google_tag_container');
+      $container->enforceIsNew();
+      $container->set('id', $variables->id);
+      // @todo This has unintended collateral effect; the id property is gone forever.
+      // Code in submitContainers() needs this value.
+      $values = (array) $variables;
+      unset($values['id']);
+      array_walk($values, function ($value, $key) use ($container) {
+        $container->$key = $value;
+      });
+      // Save container.
+      $container->save();
+
+      // Create snippet files.
+      $manager = \Drupal::service('google_tag.container_manager');
+      $manager->createAssets($container);
+    }
+  }
+
+  /**
+   * Delete containers from the database and delete snippet files.
+   */
+  protected function deleteContainers() {
+    // Delete containers.
+    foreach ($this->variables as $key => $variables) {
+      // also \Drupal::entityTypeManager()
+      $container = \Drupal::service('entity_type.manager')->getStorage('google_tag_container')->load($key);
+      $container->delete();
+    }
+
+    // Confirm no containers.
+    $manager = \Drupal::service('google_tag.container_manager');
+    $ids = $manager->loadContainerIDs();
+    $message = 'No containers found after delete';
+    parent::assertTrue(empty($ids), $message);
+
+    // @todo Next statement will not delete files as containers are gone.
+    // $manager->createAllAssets();
+    // Delete snippet files.
+    $directory = \Drupal::config('google_tag.settings')->get('uri');
+    if (\Drupal::config('google_tag.settings')->get('flush_snippets')) {
+      if (!empty($directory)) {
+        // Remove any stale files (e.g. module update or machine name change).
+        \Drupal::service('file_system')->deleteRecursive($directory . '/google_tag');
+      }
+    }
+
+    // Confirm no snippet files.
+    $message = 'No snippet files found after delete';
+    parent::assertTrue(!is_dir($directory . '/google_tag'), $message);
+  }
+
+  /**
+   * Add containers through user interface.
+   */
+  protected function submitContainers() {
+    $this->drupalLogin($this->adminUser);
+
+    foreach ($this->variables as $key => $variables) {
+      $edit = (array) $variables;
+      $this->drupalPostForm('/admin/config/system/google-tag/add', $edit, 'Save');
+
+      $text = 'Created @count snippet files for %container container based on configuration.';
+      $args = array('@count' => 3, '%container' => $variables->label);
+      $text = t($text, $args);
+      $message = 'Found snippet confirmation message in page response';
+      $this->assertRaw($text, $message);
+
+      $text = 'Created @count snippet files for @container container based on configuration.';
+      $args = array('@count' => 3, '@container' => $variables->label);
+      $text = t($text, $args);
+      $this->assertText($text, $message);
+    }
+  }
+
+  /**
+   * Inspect the snippet files.
+   */
+  protected function checkSnippetFiles() {
+  }
+
+  /**
+   * Verify the snippet file contents.
+   */
+  protected function verifyScriptSnippet($contents, $variables) {
+    $status = strpos($contents, "'$variables->container_id'") !== FALSE;
+    $message = 'Found in script snippet file: container_id';
+    parent::assertTrue($status, $message);
+
+    $status = strpos($contents, "gtm_preview=$variables->environment_id") !== FALSE;
+    $message = 'Found in script snippet file: environment_id';
+    parent::assertTrue($status, $message);
+
+    $status = strpos($contents, "gtm_auth=$variables->environment_token") !== FALSE;
+    $message = 'Found in script snippet file: environment_token';
+    parent::assertTrue($status, $message);
+  }
+
+  /**
+   * Verify the snippet file contents.
+   */
+  protected function verifyNoScriptSnippet($contents, $variables) {
+    $status = strpos($contents, "id=$variables->container_id") !== FALSE;
+    $message = 'Found in noscript snippet file: container_id';
+    parent::assertTrue($status, $message);
+
+    $status = strpos($contents, "gtm_preview=$variables->environment_id") !== FALSE;
+    $message = 'Found in noscript snippet file: environment_id';
+    parent::assertTrue($status, $message);
+
+    $status = strpos($contents, "gtm_auth=$variables->environment_token") !== FALSE;
+    $message = 'Found in noscript snippet file: environment_token';
+    parent::assertTrue($status, $message);
+  }
+
+  /**
+   * Verify the snippet file contents.
+   */
+  protected function verifyDataLayerSnippet($contents, $variables) {
+  }
+
+  /**
+   * Inspect the page response.
+   */
+  protected function checkPageResponse() {
+    $this->drupalLogin($this->nonAdminUser);
+  }
+
+  /**
+   * Verify the tag in page response.
+   */
+  protected function verifyScriptTag($realpath) {
+    $query_string = \Drupal::state()->get('system.css_js_query_string') ?: '0';
+    $text = "src=\"$realpath?$query_string\"";
+    $this->assertSession()->responseContains($text);
+
+    $xpath = "//script[@src=\"$realpath?$query_string\"]";
+    $elements = $this->xpath($xpath);
+    $status = !empty($elements);
+    $message = 'Found script tag in page response';
+    parent::assertTrue($status, $message);
+  }
+
+  /**
+   * Verify the tag in page response.
+   */
+  protected function verifyNoScriptTag($realpath, $variables) {
+    // The tags are sorted by weight.
+    $index = isset($variables->weight) ? $variables->weight - 1 : 0;
+    $xpath = '//noscript//iframe';
+    $elements = $this->xpath($xpath);
+    $contents = $elements[$index]->getAttribute('src');
+
+    $status = strpos($contents, "id=$variables->container_id") !== FALSE;
+    $message = 'Found in noscript tag: container_id';
+    parent::assertTrue($status, $message);
+
+    $status = strpos($contents, "gtm_preview=$variables->environment_id") !== FALSE;
+    $message = 'Found in noscript tag: environment_id';
+    parent::assertTrue($status, $message);
+
+    $status = strpos($contents, "gtm_auth=$variables->environment_token") !== FALSE;
+    $message = 'Found in noscript tag: environment_token';
+    parent::assertTrue($status, $message);
+  }
+
+}