Zum Inhalt springen
Menschenlesbare URL-Fragmente in TYPO3

Lesbare Sprungmarken für TYPO3-Inhaltselemente

Sprechende URL-Fragmente (#interessanter-absatz) sowie Anker-Links in TYPO3 – vom Redakteur pflegbar.

Nachdem ich menschenlesbare Sprungmarken (oder Fragment­bezeichner, wie der korrekte Name lautet) in meiner Website eingerichtet hatte, wollte ich dieses Tutorial schreiben. Zum besseren Verständnis hatte ich noch eine einfache Demo-Extension zum Tutorial erstellt.

Diese Extension habe ich letztlich so erweitert, dass ich sie im TYPO3 Extension Repository (TER) veröffentlichen konnte. So ist aus dem ursprünglich geplanten Tutorial eine Art Making-of geworden, in dem ich auf die technischen Details und Überlegungen eingehen werde.

EXT:content_slug im TER herunterladen

Anforderungen an die Fragmente

  • Die Fragmente sollen gut lesbar sein, also #thema-des-abschnitts statt #c246
  • Die Fragmente auf einer Seite müssen einzigartig sein.
  • Der Redakteur soll die Fragmente manuell einrichten können.
  • Die vom Redakteur gesetzten Fragmentbezeichner dürfen nicht mit id Attributen des HTML-Templates übereinstimmen.
  • Sonderzeichen und Umlaute sollen automatisch ersetzt werden.
  • Weitere Funktion: der Redakteur kann optional einen Anker-Link neben der Überschrift aktivieren.

Einrichten der neuen Datenbankfelder

Dies zeige ich nicht nur der Vollständigkeit halber. In der TCA-Definition des Fragment-Feldes werden drei Evaluierungs-Funktionen aufgerufen, auf die ich weiter unten näher eingehen werde.

Neben dem Text-Feld für das Fragment ergänze ich noch die Checkbox, mit der die Ausgabe des Anker-Links gesteuert werden kann.

Im Original verwende ich natürlich mehrsprachige Labels mit XLF-Dateien. Zur Vereinfachung verzichte ich aber in den folgenden Beispielen darauf.

ext_tables.sql

CREATE TABLE tt_content (
    tx_content_slug_fragment varchar(255) DEFAULT '' NOT NULL,
    tx_content_slug_link TINYINT(1) UNSIGNED DEFAULT '0' NOT NULL
);

 

Configuration/TCA/Overrides/tt_content.php

<?php
defined('TYPO3_MODE') or die();

// Konfiguration der neuen Felder:
$fields = array(
    'tx_content_slug_fragment' => [
        'exclude' => true,
        'label' => 'Lesbare URL #Sprungmarke',
        'config' => [
            'type' => 'input',
            'size' => 50,
            'max' => 80,
            'eval' => 'trim,Sebkln\\ContentSlug\\Evaluation\\FragmentEvaluation,uniqueInPid',
            'default' => ''
        ],
    ],
    'tx_content_slug_link' => [
        'exclude' => true,
        'label' => 'Link zu #Sprungmarke setzen',
        'config' => [
            'type' => 'check',
            'items' => [
                ['Aktivieren', ''],
            ],
        ],
    ]
);

// Die neuen Felder in einer existierenden Tabellen-Definition ergänzen:
\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addTCAcolumns('tt_content', $fields);

// Die neuen Felder einer existierenden Palette hinzufügen:
\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addFieldsToPalette(
    'tt_content', // Die Tabelle für TYPO3-Inhaltselemente.
    'headers', // Bestehende Palette für den Header betreffende Felder.
    '--linebreak--, tx_content_slug_fragment, tx_content_slug_link', // Die neuen Felder, ergänzt nach einem Zeilenumbruch.
    'after:header_link' // Position der neuen Felder.
);

Evaluierung der URL-Fragmente

Die Verwendung von Zeichen in URL-Fragmenten bzw. id Attributen ist begrenzt. Mit HTML 5 wurden die Regeln zwar gelockert, aus Kompatibilitätsgründen greifen in dieser Extension aber die Regeln von HTML 4.

Die URL-Fragmente dürfen daher die folgenden Zeichen beinhalten:

  • ASCII-Zeichen (a-z)
  • Ziffern (0-9)
  • Unterstriche (_)
  • Bindestriche (-)
  • Punkte (.)

Außerdem darf ein Fragmentbezeichner nur einmal auf jeder Unterseite vorkommen. Sonst könnte der Browser ja auch nicht entscheiden, zu welchem Fragment er springen soll.

Daher werden insgesamt drei TCA-Evaluierungen bzw. Validierungen auf das Fragment-Feld angewendet.

1.) trim

Das ist schnell erklärt: wir entfernen Weißraum, der ggf. am Anfang und Ende des Feldwertes eingetragen wurde.

Dies wird sofort angewendet, wenn der Redakteur das Eingabefeld verlässt.

2.) Sebkln\ContentSlug\Evaluation\FragmentEvaluation

Dies ist eine benutzerdefinierte Feld-Evaluierung in meiner Extension, die im TCA durch einen PHP Namespace aufgerufen wird.

In einem ersten Versuch hatte ich stattdessen ein TCA-Feld vom Typ "slug" eingerichtet. Das erschien erst mal vielversprechend – ich konnte im TCA einrichten, dass mein neues Fragment-Feld mit dem Inhalt des Header-Feldes befüllt wird. Wie beim Slug-Feld für Seiten konnte ein Redakteur dies auch durch einen Button neben dem Feld erledigen.
Es hatte aber auch zwei Nachteile: erstens wurde das Fragment-Feld automatisch zum Pflichtfeld, was nicht immer erwünscht sein dürfte. Zweitens filterte die Slug-Evaluierung zwar einige Zeichen heraus – den Schrägstrich aber zum Beispiel nicht. Und der darf in URL-Fragmenten nicht verwendet werden.

Also musste eine andere Vorgehensweise her. Glücklicherweise wird die Erstellung einer eigenen TCA-Evaluierung in der offiziellen TYPO3-Dokumentation sehr gut beschrieben. Das erlaubte mir, die nun folgende Lösung zu erstellen.

Ein gelernter TYPO3-Entwickler hätte hier vielleicht einen anderen Lösungsansatz gewählt, wie z.B. eine Erweiterung des TCA-Typs "slug". Als Integrator sind meine PHP-Kenntnisse begrenzt – ich denke aber schon, dass meine Methode sauber umgesetzt ist.

Diese Prüfung wird beim Speichern alle nicht erlaubten Zeichen ersetzen.

ext_localconf.php

<?php
defined('TYPO3_MODE') or die();

$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tce']['formevals']['Sebkln\\ContentSlug\\Evaluation\\FragmentEvaluation'] = '';

Zuerst müssen wir unsere neue Klasse zur Evaluierung hier registrieren.

Classes/Evaluation/FragmentEvaluation.php

<?php
namespace Sebkln\ContentSlug\Evaluation;

use TYPO3\CMS\Core\Charset\CharsetConverter;
use TYPO3\CMS\Core\Utility\GeneralUtility;

class FragmentEvaluation
{
    public function returnFieldJS()
    {
        return 'return value;';
    }

    public function evaluateFieldValue($value)
    {
        $value = $this->sanitizeFragment($value);
        return $value;
    }

    public function deevaluateFieldValue(array $parameters)
    {
        return $parameters['value'];
    }
    
    public function sanitizeFragment(string $slug): string
    {
        // In Kleinbuchstaben umwandeln und HTML-Tags entfernen:
        $slug = mb_strtolower($slug, 'utf-8');
        $slug = strip_tags($slug);

        // Leerzeichen durch Bindestriche ersetzen:
        $fallbackCharacter = ('-');
        $slug = preg_replace('/[ \t\x{00A0}]+/u', $fallbackCharacter, $slug);

        // Erweiterte Zeichen in ASCII-Äquivalente umwandeln.
        // specCharsToASCII() ersetzt "€" durch "EUR".
        $slug = GeneralUtility::makeInstance(CharsetConverter::class)->specCharsToASCII('utf-8', $slug);

        // Nur zulässige Zeichen behalten:
        $slug = preg_replace('/[^\p{L}\p{M}0-9\-_.' . preg_quote($fallbackCharacter) . ']/u', '', $slug);

        // Mehrfach aufeinanderfolgende Bindestriche durch einen einzelnen ersetzen:
        if ($fallbackCharacter !== '') {
            $slug = preg_replace('/' . preg_quote($fallbackCharacter) . '{2,}/', $fallbackCharacter, $slug);
        }

        // Erneut in Kleinbuchstaben umwandeln, nachdem alle Ersetzungen vorgenommen wurden:
        $slug = mb_strtolower($slug, 'utf-8');

        return $slug;
    }
}

Eine Klasse zur Evaluierung besteht in TYPO3 immer aus drei Funktionen:

  1. returnFieldJS()
    JavaScript-Evaluierung, die angewendet wird, wenn der Nutzer das Feld verlässt. Ich plane, dies noch zu ergänzen.
  2. evaluateFieldValue()
    Diese Funktion wird beim Speichern des Datensatzes aufgerufen. Ich rufe hier eine weitere Funktion namens sanitizeFragment() auf.
  3. deevaluateFieldValue()
    Diese Funktion wird beim Öffnen des Datensatzes ausgeführt. Derzeit sehe ich da keinen Vorteil für das Fragment-Feld. Belehrt mich hier gerne eines Besseren, dann ergänze ich auch das.

sanitizeFragment()

Innerhalb von evaluateFieldValue() greife ich auf diese selbst erstellte Funktion zu. Zum Glück gibt es bereits eine Evaluierung für URL-Segmente (Slug) im TYPO3-Kern, die ich als Vorlage verwenden konnte: die Funktion sanitize() in der Klasse SlugHelper.

So musste ich nur die vorhandenen Regex-Patterns an die erlaubten Zeichen eines URL-Fragments anpassen sowie ein paar Zeilen entfernen, die Schrägstriche für Slugs besonders behandelten.

Durch sanitizeFragment() werden …

  • ... alle Zeichen in Kleinbuchstaben umgewandelt.
  • ... HTML-Elemente vollständig entfernt.
  • ... Leerzeichen in das Bindestrich-Zeichen umgewandelt.
  • ... Sonderzeichen (z.B. äöü߀) in ASCII-Äquivalente umgewandelt.

3.) uniqueInPid

id Attribute dürfen auf jeder Webseite nur einmalig vorkommen. Daher verwenden wir uniqueInPid, um dies bei den URL-Fragmenten sicherzustellen.

Kleiner Wermutstropfen: aktuell unterscheidet uniqueInPid nicht zwischen den verschiedenen Sprachen auf einer Unterseite, daher können keine identischen Fragmente in Übersetzungen verwendet werden. Hierzu habe ich ein Issue auf TYPO3 Forge eröffnet.

Vorsichtsmaßnahmen in Bezug auf id-Attribute

Das vom Redakteur gesetzte Fragment wird in der Überschrift des Inhaltselements als id Attribut ergänzt:

<h2 id="das-fragment">
    Eine Überschrift
    <a class="headline-anchor" href="#das-fragment">#</a>
</h2>

Ich gehe einmal davon aus, dass ihr id Attribute auch in eurem Website-Template verwendet. Sei es für JavaScript-Funktionalitäten, sei es in Stylesheets. Daher ist euch bewusst, dass ein versehentlich vom Redakteur gesetztes Fragment mit identischem Wert eure Website ungewollt beeinflussen könnte, etwa bei #site-header oder #content.

Um das zu verhindern, verwendet diese Extension einen Präfix bei allen URL-Fragmenten, nämlich die UID des Inhaltselements (#c123-lesbares-fragment).

Ihr könnt auf den Präfix komplett verzichten, wenn ihr eure Templates anpasst: Verwendet dort ausschließlich id Attribute mit Großbuchstaben oder zwei aufeinanderfolgenden Bindestrichen. Da die TCA-Evaluierung diese beiden Schreibweisen im Eingabefeld immer umwandeln wird, werden Duplikate so ausgeschlossen.

Anpassen der Fluid-Templates von fluid_styled_content

Wir kommen nicht darum herum, ein paar Fluid-Templates anzupassen. Die neuen Variablen wollen ja irgendwie im HTML ausgegeben werden.

TypoScript

Aktuell verwendet die Extension TypoScript nur für Template-Pfade:

lib.contentElement {
    partialRootPaths.101 = EXT:content_slug/Resources/Private/Overrides/fluid_styled_content/Partials/
    templateRootPaths.101 = EXT:content_slug/Resources/Private/Overrides/fluid_styled_content/Templates/
}

Eingebunden werden kann das TypoScript über die Static templates, so dass die URL-Fragmente nur für ausgewählte Seitenbäume in TYPO3 bereitgestellt werden können.

Anpassung der Header-Partials

All.html

<f:render partial="Header/Header" arguments="{
    header: data.header,
    layout: data.header_layout,
    positionClass: '{f:if(condition: data.header_position, then: \'ce-headline-{data.header_position}\')}',
    link: data.header_link,
    uid: data.uid,
    fragmentIdentifier: data.tx_content_slug_fragment,
    renderAnchorLink: data.tx_content_slug_link,
    default: settings.defaultHeaderType}" />

Die Werte der neuen Datenbankfelder sind hier in den Variablen {data.tx_content_slug_fragment} und {data.tx_content_slug_link} verfügbar. Dem Partial Header.html werden aber nur ausgewählte Variablen als Argumente übergeben. Daher müssen wir die neuen Variablen sowie die UID hier noch ergänzen.

Header.html

<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" data-namespace-typo3-fluid="true">

<f:if condition="{header}">
    <f:switch expression="{layout}">
        <f:case value="1">
            <h1 id="{f:if(condition: fragmentIdentifier, then: 'c{uid}-{fragmentIdentifier}')}" class="{positionClass}">
                <f:link.typolink parameter="{link}">{header}</f:link.typolink>
                <f:if condition="{fragmentIdentifier} && {renderAnchorLink}"><a class="headline-anchor" href="#c{uid}-{fragmentIdentifier}">#</a></f:if>
            </h1>
        </f:case>
        <f:case value="2">
            <h2 id="{f:if(condition: fragmentIdentifier, then: 'c{uid}-{fragmentIdentifier}')}" class="{positionClass}">
                <f:link.typolink parameter="{link}">{header}</f:link.typolink>
                <f:if condition="{fragmentIdentifier} && {renderAnchorLink}"><a class="headline-anchor" href="#c{uid}-{fragmentIdentifier}">#</a></f:if>
            </h2>
        </f:case>
        <f:case value="3">
            <h3 id="{f:if(condition: fragmentIdentifier, then: 'c{uid}-{fragmentIdentifier}')}" class="{positionClass}">
                <f:link.typolink parameter="{link}">{header}</f:link.typolink>
                <f:if condition="{fragmentIdentifier} && {renderAnchorLink}"><a class="headline-anchor" href="#c{uid}-{fragmentIdentifier}">#</a></f:if>
            </h3>
        </f:case>
        <f:case value="4">
            <h4 id="{f:if(condition: fragmentIdentifier, then: 'c{uid}-{fragmentIdentifier}')}" class="{positionClass}">
                <f:link.typolink parameter="{link}">{header}</f:link.typolink>
                <f:if condition="{fragmentIdentifier} && {renderAnchorLink}"><a class="headline-anchor" href="#c{uid}-{fragmentIdentifier}">#</a></f:if>
            </h4>
        </f:case>
        <f:case value="5">
            <h5 id="{f:if(condition: fragmentIdentifier, then: 'c{uid}-{fragmentIdentifier}')}" class="{positionClass}">
                <f:link.typolink parameter="{link}">{header}</f:link.typolink>
                <f:if condition="{fragmentIdentifier} && {renderAnchorLink}"><a class="headline-anchor" href="#c{uid}-{fragmentIdentifier}">#</a></f:if>
            </h5>
        </f:case>
        <f:case value="6">
            <h6 id="{f:if(condition: fragmentIdentifier, then: 'c{uid}-{fragmentIdentifier}')}" class="{positionClass}">
                <f:link.typolink parameter="{link}">{header}</f:link.typolink>
                <f:if condition="{fragmentIdentifier} && {renderAnchorLink}"><a class="headline-anchor" href="#c{uid}-{fragmentIdentifier}">#</a></f:if>
            </h6>
        </f:case>
        <f:case value="100">
            <f:comment> -- do not show header --</f:comment>
        </f:case>
        <f:defaultCase>
            <f:if condition="{default}">
                <f:render partial="Header/Header" arguments="{
                    header: header,
                    layout: default,
                    positionClass: positionClass,
                    uid: uid,
                    fragmentIdentifier: fragmentIdentifier,
                    renderAnchorLink: renderAnchorLink,
                    link: link}"/>
            </f:if>
        </f:defaultCase>
    </f:switch>
</f:if>
</html>

Hier werden mehrere Elemente ergänzt:

  • Jede Überschrift (<h1> bis <h5>) erhält ein neues id Attribut. Hier wird dann die aktuelle UID sowie das gesetzte Fragment ausgegeben, sofern ein Fragment existiert.
  • Zwei Zeilen darunter wird jeweils ein Anker-Link ergänzt. Zwei Bedingungen müssen dafür erfüllt sein:
    1. Der Redakteur muss den Anker-Link im Inhaltselement aktiviert haben (per Checkbox).
    2. Ein Fragment muss existieren (sonst wäre der Link leer).
  • Am Ende des Partials erfolgt ein rekursiver Aufruf für den Fall, dass ein Inhaltselement das Default-Headerlayout verwendet. Hier müssen unsere Variablen erneut übergeben werden, sonst fehlen sie dem Default-Header.

Menü-Elemente vom Typ "Section Index"

Die Pflicht ist getan, jetzt kommt die Kür: die beiden TYPO3-Inhaltselemente "Section Index" sowie "Section index of subpages from selected pages" erstellen Seitenmenüs, in denen auch die jeweiligen Seiteninhalte aufgelistet werden. Diese sind mit der aktuellen UID verlinkt, so dass der Nutzer direkt zum Inhalt navigieren kann.

Jetzt wäre es doch toll, wenn hier auch die neuen URL-Fragmente verwendet würden! Für diesen Zweck passen wir die beiden Fluid-Templates etwas an.

<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" data-namespace-typo3-fluid="true">

<f:layout name="Default" />
<f:section name="Main">

    <f:if condition="{menu}">
        <ul>
            <f:for each="{menu}" as="page">
                <li>
                    <a href="{page.link}"{f:if(condition: page.target, then: ' target="{page.target}"')} title="{page.title}">
                        <span>{page.title}</span>
                    </a>
                    <f:if condition="{page.content}">
                        <ul>
                            <f:for each="{page.content}" as="element">
                                <f:if condition="{element.data.header}">
                                <li>
                                    <a href="{page.link}#{f:if(condition: '({element.data.tx_content_slug_fragment} && {element.data.header_layout} != 100)', then: 'c{element.data.uid}-{element.data.tx_content_slug_fragment}', else: 'c{element.data.uid}')}"
                                       {f:if(condition: page.target, then: ' target="{page.target}"')} title="{element.data.header}">
                                        <span>{element.data.header}</span>
                                    </a>
                                </li>
                                </f:if>
                            </f:for>
                        </ul>
                    </f:if>
                </li>
            </f:for>
        </ul>
    </f:if>

</f:section>
</html>

In Zeile 18 ergänzen wir eine neue Fluid-Condition, die zwei Dinge prüft:

  1. Gibt es ein lesbares URL-Fragment für das aktuelle Inhaltselement?
  2. Auch wichtig: Ist der Header überhaupt sichtbar? Sonst würde das URL-Fragment nicht gerendert werden und die Sprungmarke des Section Index Menüs zielt ins Leere.

Falls beide Bedingungen erfüllt sind, wird das menschenlesbare URL-Fragment auch im Link des Section Index verwendet. Ansonsten wird der übliche Link mit der UID des Inhaltselements ausgegeben.

Auch hier wird die UID zusätzlich als Präfix verwendet! Wenn du das Präfix auf deiner Website ändern oder entfernen möchtest, musst du dies also auch in diesen Templates durchführen.

Eine kleine Anmerkung: Die UID gebe ich in Zeile 18 in beiden Fällen aus (then und else).

then: 'c{element.data.uid}-{element.data.tx_content_slug_fragment}', else: 'c{element.data.uid}'

Die Fluid-Condition könnte ich also theoretisch vereinfachen, indem ich die UID außerhalb der Condition immer rendere. Allerdings müsste dann jeder Integrator daran denken, diese Condition wieder zu erweitern, sobald er das Präfix anpasst oder löscht: für Inhalte ohne eigenes Fragment bleibt die UID ja notwendig.

Hier gelten die gleichen Ergänzungen wie in MenuSection.html.

Fazit

Die Erstellung dieser Extension hat viel Spaß gemacht, allerdings erforderte selbst dieses simple Feature einigen Aufwand. Ich habe allergrößten Respekt vor Entwicklern, die komplexe TYPO3-Extensions dauerhaft betreuen – über verschiedene Versionen und Anwendungsfälle hinweg.

Ich plane noch einige Verbesserungen in der Extension und werde die Dokumentation ergänzen. Über Feedback zur Extension freue ich mich natürlich: hilft sie euch? Habt ihr sie irgendwo im Einsatz?

Fehlermeldungen und Verbesserungsvorschläge könnt ihr gerne auf GitHub melden.

Zurück