SVG Icon Sprites, Nunjucks, Gulp

Für die Einbindung von Icons nutze ich aus vielerlei Gründen extern referenzierte SVG Sprites, deren Fragmente im HTML über das <use> Attribut des <svg> Elements inline referenziert werden. Um redundante Schreibarbeit zu vermeiden und die Pflege deutlich zu erleichtern, bietet sich der Einsatz einer Template Engine an.

Worum es geht

Im folgenden Beitrag dreht sich alles um die Einbindung von Referenzen eines SVG-Sprites und wie man sich das (Arbeits)Leben durch Einsatz einer Template Engine (Nunjucks) und einem Gulp Workflow einfacher machen kann.

Nicht explizit beschrieben wird die Erstellung von SVG-Sprites (wäre was für einen weiteren Beitrag), Sinn und Zweck von Template Engines und/oder die Arbeit mit Gulp.

Wie es ist

Referenziert man ein SVG-Sprite im HTML Markup, kann das u.a. schon mal einiges an Tipparbeit (pro Referenz) bedeuten. Zumal, wenn wir - was obligatorisch sein sollte - Wert auf größtmögliche Zugänglichkeit des SVG legen.

HTML Markup:

<svg class="icon icon--huge icon-twitter" role="img" aria-labelledby="title-1668 desc-1668">
  <title id="title-1668">Titel...</title>
  <desc id="desc-1668">Beschreibung...</desc>
  <use xlink:href="assets/img/icon-sprite.svg#icon-twitter" />
</svg>

Das ist eine ganze Menge Markup für die Darstellung eines kleinen Icons.

Ja, aber: Ob und welche Attribute des oben gezeigtes Beispiel für ein Icon sinnvoll sind, bestimmt allein der Kontext, in dem sie verwendet werden.

In meinen bisherigen Projekten gab es bislang folgende Arten von Kontext:

  1. Icon, das inhaltliche Bedeutung hat (nicht verlinkt)
  2. Icon, das dekorativer Natur ist + Text (nicht verlinkt)
  3. Icon, das dekorativer Natur ist (verlinkt)
  4. Icon, das dekorativer Natur ist + Text (verlinkt)

Das bedingt sehr unterschiedliche Arten der Referenzierung, resp. welche Attribute wie und ob überhaupt gesetzt werden sollten:

1.) HTML Markup: Icon, das inhaltliche Bedeutung hat (nicht verlinkt)

<svg class="icon icon-smartphone" role="img" aria-labelledby="title-1668 desc-1668">
  <title id="title-1668">Smartphone</title>
  <desc id="desc-1668">Optimierte Darstellung für mobile Geräte</desc>
  <use xlink:href="assets/img/icon-sprite.svg#icon-smartphone" />
</svg>
2.) HTML Markup: Icon, das dekorativer Natur ist + Text (nicht verlinkt)

<svg class="icon icon-success" role="img" aria-hidden="true">
  <use xlink:href="assets/img/icon-sprite.svg#icon-success" />
</svg> 
Ihre Eingaben wurden erfolgreich gespeichert
3.) HTML Markup: Icon, das dekorativer Natur ist (verlinkt)

<a href="#0" title="Titel..." aria-label="Labeltext...">
  <svg class="icon icon-twitter" role="img">
    <use xlink:href="assets/img/icon-sprite.svg#icon-twitter" />
  </svg>
</a>
4.) HTML Markup: Icon, das dekorativer Natur ist + Text (verlinkt)

<a href="#0" aria-label="Labeltext...">
  <svg class="icon icon-twitter" role="img">
    <use xlink:href="assets/img/icon-sprite.svg#icon-twitter" />
  </svg>
  Twitter
</a>

Was wir wollen

Wie die vorigen Beispiele zeigen, müssen wir ggf. sehr unterschiedliches Markup schreiben. Je nach Kontext des Icons. Das bedeutet viel Tipparbeit, Anfälligkeit für Fehler und es wird mit zunehmenden Anzahl der Referenzen schwierig den Überblick zu behalten. Das muss anders gehen...

Eine Möglichkeit ist der Einsatz einer Template Engine*. Alle mir bekannten Engines beinhalten - teilweise unterschiedlich benannt - ähnliche Funktionalitäten: Partials, Includes und Macros.

Diese können wir nutzen, um das Markup der SVG-Sprite Referenzen in einer eigenen Template-Datei auszulagern und dann mit unterschiedlichen Parametern in unser Haupt-Template einzubinden.

Hugo Giraudel hatte diese Idee letztes Jahr schon beschrieben. Da ich nicht mit dem von ihm genutzten Static Site Generator Jeckyll arbeite, musste ich mich nach einer Alternative umschauen. Und habe mich für Nunjucks als Template Engine entschieden.

Jeckyll erlaubt in Includes eine Parameterübergabe, Nunjucks nicht. Mit den Macros, die Nunjucks bereitstellt, erreichen wir - wenn auch auf einem leicht anderen Weg - das gleiche Ziel.

*) Alternativ sind lokale Lösungen wie Snippets für den Lieblings-Editor denkbar

Macro anlegen, importieren und anwenden

Ein macro besteht aus einer Datei, die eine oder mehrere Funktionen enthält, welche dann beliebig oft im Haupt-Template genutzt werden können.

Für unser Anliegen reichen zwei Funktionen: mean und loose. Mit den beiden können wir die o.g. Szenarien abdecken (s. auch nächster Abschnitt).

Nunjucks Template: Macro (Datei: templates/macros/icon.nunjucks)

// s. 1. Icon, das inhaltliche Bedeutung hat (nicht verlinkt)

{% macro mean(file, title, desc, size, uid = randomHash()) -%}
<svg class="icon {% if size %}icon--{{ size }} {% endif %}icon-{{ file }}" role="img" {% if title or desc %}aria-labelledby="{% if title %}title-{{ uid }}{% endif %}{% if desc %} desc-{{ uid }}{% endif %}"{% endif %}>
{%- if title -%}
<title id="title-{{ uid }}">{{ title }}</title>
{%- endif -%}
{%- if desc -%}
<desc id="desc-{{ uid }}">{{ desc }}</desc>
{%- endif -%}
<use xlink:href="assets/img/icon-sprite.svg#icon-{{ file }}" /></svg>
{%- endmacro %}


// s. 2. Icon, das dekorativer Natur ist + Text (nicht verlinkt)
// s. 3. Icon, das dekorativer Natur ist (verlinkt)
// s. 4. Icon, das dekorativer Natur ist + Text (verlinkt)

{% macro loose(file, ariaHiddenStatus, size) -%}
<svg class="icon {% if size %}icon--{{ size }} {% endif %}icon-{{ file }}" role="img"{% if ariaHiddenStatus %} aria-hidden="{{ ariaHiddenStatus }}"{% endif %}><use xlink:href="assets/img/icon-sprite.svg#icon-{{ file }}" /></svg>
{%- endmacro %}

Um die Funktionen des Macros einsetzen zu können, müssen wir die Macro-Datei zuerst in das Haupt-Template importieren (vor dem Aufruf der Funktionen):

Nunjucks Template: Macro importieren (Datei: templates/index.nunjucks)

{% import 'macros/icon.nunjucks' as icon %}

Anschließend stehen die Funktionen des Macros zur Verfügung.

Nunjucks Template: Macro Funktionen anwenden (Datei: templates/index.nunjucks)

// Beispiele...

// s. 1. Icon, das inhaltliche Bedeutung hat (nicht verlinkt)
{{ icon.mean("smartphone", "Smartphone", "Optimierte Darstellung für mobile Geräte") }}

// s. 2. Icon, das dekorativer Natur ist + Text (nicht verlinkt)
{{ icon.loose("success", "true") }} Ihre Eingaben wurden erfolgreich gespeichert

Wird das Template nun gerendert, entspricht das generierte Markup dem HTML aus den Beispielen 1(eins) und 2(zwei).

Die beiden Macro-Funktionen im Detail

Aber der Reihe nach. Wann nutze ich welche Funktion (mean(), loose()) und was für Parameter stehen zur Verfügung?

icon.mean()

Es gibt für die Funktion mean() nur einen sinnvollen Anwendungsfall: Wenn ein allein stehendes Icon eine Aussage transportieren soll und nicht nur der Dekoration dient. Hier muss sichergestellt werden, dass assistive Geräte/Anwendungen die Bedeutung auch erfassen können (durch den Einsatz von "title" und "description").

Nunjucks Template: Macro Funktion mean()

// Mögliche Parameter
{{ icon.mean("[file]", "[title]", "[description]", "[size]") }}

// Es muss mindestens der Fragment ID (Parameter 'file') definiert sein (SVG-Sprite: <symbol id="[x]">)
{{ icon.mean("smartphone") }}

// Beispiele

// Parameter: file, title, description
{{ icon.mean("smartphone", "Smartphone", "Optimierte Darstellung für mobile Geräte") }}

// Parameter: file, title, description, size (Icon size modifier class (.`icon--huge`))
{{ icon.mean("smartphone", "Smartphone", "Optimierte Darstellung für mobile Geräte", "huge") }}

// Parameter: file, title, keine(!) description, size (Icon size modifier class (.`icon--large`))
{{ icon.mean("smartphone", "Smartphone", "", "large") }}

// Parameter: file, title
{{ icon.mean("smartphone", "Smartphone") }}

icon.loose()

Die Funktion loose() hingegen setzen wir in allen anderen Szenarien ein. In diesen (s. Beispiele) kann auf Titel und Beschreibung verzichtet werden, ohne das die Zugänglichkeit leidet.

Nunjucks Template: Macro Funktion loose()

// Mögliche Parameter
{{ icon.loose("[file]", "[ariaHiddenStatus]", "[size]") }}

// Es muss mindestens der Fragment ID (Parameter 'file') definiert sein (SVG-Sprite: <symbol id="[x]">)
{{ icon.loose("smartphone") }}

// Beispiele

// Parameter: file, ariaHiddenStatus
{{ icon.loose("success", "true") }}

// Parameter: file, ariaHiddenStatus, size (Icon size modifier class (.`icon--huge`))
{{ icon.loose("success", "true", "huge") }}

// Parameter: file, size (Icon size modifier class (.`icon--huge`))
{{ icon.loose("smartphone", "", "huge") }}

// Parameter: file
<a href="#0" title="Gehe zur Twitter Seite" aria-label="Gehe zur Twitter Seite">
  {{ icon.loose("twitter") }}
</a>

// Parameter: file
<a href="#0" aria-label="Gehe zur Twitter Seite">
  {{ icon.loose("twitter") }}
  Twitter
</a>

Nunjucks installieren und in Gulp integrieren

Das Module wird über über den Node.js Package Manager installiert,

Bash: Plugin installieren

$ npm install gulp-nunjucks-render --save-dev

dann im Gulpfile.js in einer Variable gespeichert,

Gulpfile: Plugin einbinden

var nunjucksRender = require('gulp-nunjucks-render');

die dann im neu anzulegenden Gulp Task genutzt wird.

Gulpfile: Der (vereinfachte) Gulp Task für das Templates Rendering

gulp.task('process:templates', function() {
  return gulp.src('templates/*.nunjucks')
    ...   
    .pipe(nunjucksRender({
      path: 'templates/',
      manageEnv: expandEnv
    }))
    .pipe(gulp.dest('app/'));
});

Wenn wir den Task jetzt allerdings über die Bash aufrufen würden ($ gulp process:templates), würde es eine Fehlermeldung hageln - schauen wir uns die Pipe Aufruf für nunjucksRender an, sehen wir, dass es einen Parameter mit Namen manageEnv gibt, dem eine Variable (expandEnv) zugewiesen ist. Eine Variable, die zum jetzigen Zeitpunkt weder initialisiert noch gefüllt ist.

Das holen wir jetzt in zwei Schritten nach: Zuerst fügen wir dem Gulpfile (vor dem Aufruf des Tasks) die folgende Funktion hinzu:

Gulpfile: Funktion erstellen/einfügen

/**
 * Generates random number
 * @return {string} random number
 */
function randomHash() {
    return Math.random()*0xfff|0;
}

Anschließend erweitern wir in einem zweiten Schritt die Laufzeitumgebung von Nunjucks mit der eben eingefügten Funktion:

Gulpfile: Die Laufzeitumgebung von Nunjucks mit der zuvor erstellten Funktion (`randomHash`) erweitern 

/**
 * Enhance nunjucks environment
 * @param {object} environment
 */
var expandEnv = function(environment) {
  environment.addGlobal('randomHash', randomHash);
}

Wozu brauchen wir das? Diese - jetzt global für Nunjucks verfügbare - Funktion erlaubt es eindeutige Element-IDs (uid) für jede Funktionsinstanz des Macros zu generieren (s. Quelltext des Macros, Markup-Ausgabe). Was zwingend erforderlich ist, um die Zugänglichkeit für assistive Geräte/Anwendungen zu gewährleisten.

Was es bringt

Deutlich weniger Aufwand in der Deklaration von SVG-Sprite Referenzen im Markup. Ein Blick auf die Quellcode-Beispiele sagt mehr als tausend Worte...

Vorher:

...
<svg class="icon icon-smartphone" role="img" aria-labelledby="title-1668 desc-1668">
  <title id="title-1668">Smartphone</title>
  <desc id="desc-1668">Optimierte Darstellung für mobile Geräte</desc>
  <use xlink:href="assets/img/icon-sprite.svg#icon-smartphone" />
</svg>

...

<a href="#0" aria-label="Gehe zur Twitter Seite">
  <svg class="icon icon-twitter" role="img">
    <use xlink:href="assets/img/icon-sprite.svg#icon-twitter" />
  </svg>
  Twitter
</a>

Nachher:

...
{{ icon.mean("smartphone", "Smartphone", "Optimierte Darstellung für mobile Geräte") }}

...

<a href="#0" aria-label="Gehe zur Twitter Seite">
  {{ icon.loose("twitter") }}
  Twitter
</a>