Skip to content

Latest commit

 

History

18 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

gtm-helpers

Razor tag helpers that generate Google Tag Manager dataLayer push scripts — gtm-* attributes for click events, and <gtm-datalayer /> for a page-level dataLayer block.

Targets net10.0. The GTM container script itself is not this package's job — inject it however you normally would (a CMS script field, _Layout, a consent manager).

Install

<PackageReference Include="GtmHelpers" Version="1.1.0" />

Register the tag helpers in _ViewImports.cshtml:

@addTagHelper *, GtmHelpers

Click events

Add any gtm-* attribute to an <a>, <button> or <input>:

<a href="/courses/aged-care/"
   gtm-event="courseClick"
   gtm-category="Course"
   gtm-action="view"
   gtm-label="Aged Care"
   gtm-custom-position="3">Aged Care</a>

renders

<a href="/courses/aged-care/"
   onclick="(window.dataLayer=window.dataLayer||[]).push({'event': 'courseClick', 'category': 'Course', 'action': 'view', 'label': 'Aged Care', 'position': '3', });">Aged Care</a>
Attribute dataLayer key
gtm-event event
gtm-category category
gtm-action action
gtm-label label
gtm-custom-<name> <name>

Values are strings. Empty and whitespace-only values are omitted; if nothing is left to push, no onclick is written at all.

Modes

Mode Emits CSP
InlineOnClick (default) an onclick attribute needs 'unsafe-inline' in script-src
DataAttributes a data-gtm JSON payload, no inline script works under a strict CSP

InlineOnClick is the default so that upgrading changes nothing. To switch an application over:

builder.Services.AddGtmHelpers(o => o.Mode = GtmScriptMode.DataAttributes);

and reference the shipped listener once per page:

<script src="_content/GtmHelpers/gtm-helpers.js" defer></script>

The markup is unchanged — the same gtm-* attributes — but the element renders as:

<a href="/courses/aged-care/" data-gtm='{"event":"courseClick","category":"Course","position":"3"}'>Aged Care</a>

The script binds one delegated click listener on document, in the capture phase, so it also covers elements added to the DOM after load and still fires when something downstream calls stopPropagation. A malformed payload is logged with console.warn and the click proceeds.

Individual elements can override the application default with gtm-mode="DataAttributes" (or gtm-mode="InlineOnClick"), which is mainly useful while migrating a site a page at a time.

The mode setting applies to the click attributes only — <gtm-datalayer /> always renders a <script> block, since a page-level dataLayer has nothing to attach a data attribute to.

Page-level dataLayer

<gtm-datalayer /> renders the values a page wants on window.dataLayer before the container script loads, which is what GTM's initial pageview reads. Anything pushed after gtm.js loads is missed by exactly the tags that read those values — so put it in the head, above the container snippet:

@* _Layout.cshtml, in <head> *@
<gtm-datalayer />
builder.Services.AddGtmHelpers();   // registers the scoped IGtmDataLayer the tag helper needs

Templates push onto IGtmDataLayer, a per-request accumulator. A view's code runs before the layout renders, so anything it adds is present by the time the head is written:

@inject IGtmDataLayer GtmDataLayer
@{
    GtmDataLayer.Push(new Dictionary<string, object?>
    {
        ["ProductIDList"] = new[] { 1234, 5678 },
    });
}

which renders

<script>window.dataLayer=window.dataLayer||[];window.dataLayer.push({"ProductIDList":[1234,5678]});</script>

Values are serialised with System.Text.Json, so numbers, arrays and nested objects survive as themselves — unlike the click attributes, where everything is a string.

Page defaults

To give a page the push it gets by virtue of what it is, so its template needs nothing, implement IGtmPageDefaultsProvider:

public sealed class PageDefaults : IGtmPageDefaultsProvider
{
    public IDictionary<string, object?>? GetDefaults(ViewContext viewContext)
        => viewContext.RouteData.Values["controller"] switch
        {
            "Home" => new Dictionary<string, object?> { ["PageType"] = "Homepage" },
            _      => null,   // not tracked — renders no script at all
        };
}

builder.Services.AddScoped<IGtmPageDefaultsProvider, PageDefaults>();

How a page type is identified — a route, a controller, a CMS content type alias, a view model interface — is the application's business, which is why the provider gets the whole ViewContext rather than just the model. It is optional: without one, <gtm-datalayer /> renders only what templates pushed.

The default is rendered first, so a template pushing the same key wins over the page-wide value. It is prepended at render time rather than pushed onto the accumulator, so rendering has no side effects.

Behaviour worth knowing

  • Nothing pushed renders nothing. No empty <script> element, and no push({}) — an empty push still notifies GTM's dataLayer listeners, so it is not harmless. An untracked page looks like a page that was never instrumented.
  • window.dataLayer is self-initialising here too: this block renders before the container script that would normally create the array.
  • Values are escaped as unicode<, >, & and ' — by System.Text.Json's default encoder, so no CMS- or user-supplied value can close the </script> early or otherwise contribute markup.
  • Pushes keep their order, since GTM reads the array in order.

Click behaviour worth knowing

  • window.dataLayer is self-initialising. The push is wrapped in (window.dataLayer=window.dataLayer||[]), so a click landing before the GTM container has loaded queues the event rather than throwing on an undefined global. GTM picks up the queued array when it loads.
  • An existing onclick is preserved. The generated script is appended after it, with a ; inserted if the existing handler lacks one.
  • Values are escaped for a single-quoted JavaScript string — apostrophe, backslash, quote, CR, LF, tab, and </>/& as unicode escapes. This matters more than it looks: an inline event handler is HTML-decoded before it is parsed as JavaScript, so Razor's attribute encoding does not contain a stray apostrophe. Unescaped, Master's Degree is a syntax error that kills the handler silently, and a CMS- or user-supplied value can inject script.

Limitations

  • No payload shapes. The package renders whatever dictionary it is given; it has no opinion about ecommerce event names or nesting (view_item / items, or Universal Analytics' ecommerce.detail). Those keys are a contract with your container, so they belong in your application.
  • Click values are strings. gtm-* attributes push '3' as a string; only <gtm-datalayer /> preserves numbers, arrays and nested objects.
  • Only <a>, <button> and <input> carry the click attributes. Tracking a click on anything else needs one of those in the markup, or another HtmlTargetElement.
  • No non-pageview events. Nothing here emits add_to_cart or purchase at the moment they happen — those come from the application's own JavaScript.

Building

cd src
dotnet test        # 30 tests
dotnet pack GtmHelpers/GtmHelpers.csproj -c Release

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages