von Michael Reber

Dynamische Landingpages mit Wiki.js v2

Wer mit Wiki.js v2 produktiv arbeitet, bemerkt schnell eine Lücke: Es gibt keine eingebaute Möglichkeit, auf einer Seite automatisch alle Unterseiten aufzulisten. Jede Landingpage, ob für Team-Wikis, Projektbereiche oder Dokumentationen, muss heute manuell gepflegt werden. Sobald eine neue Unterseite angelegt, verschoben oder gelöscht wird, besteht die Gefahr, dass das zentrale Inhaltsverzeichnis veraltet. In einem lebendigen Unternehmens-Wiki wie unserem ist das nicht tragbar: Wir haben über 800 Einträge und täglich kommen welche dazu.

Für Swissmakers GmbH war klar: Eine dynamische Landingpage, die sich ohne manuellen Aufwand aktualisiert, ist essenziell für konsistente Dokumentation und effizientes Auffinden von Informationen.

Warum Wiki.js v2 das nicht von Haus aus mitliefert

Die Wiki.js-Version 2 basiert intern auf einem Vue.js Single-Page-Application-Ansatz. Alle Seiteninhalte werden clientseitig nachgeladen; serverseitig generierte Platzhalter wie „[children]“ (bekannt aus anderen Markdown-Plugins) existieren nicht. Das Entwicklerteam von Wiki.js hat zwar für Version 3 ein modulares Blocks-System angekündigt, doch für v2-Instanzen bleibt nur der Weg über die öffentliche GraphQL-API oder ein Upgrade. Letzteres ist jedoch im produktiven Umfeld definitiv nicht empfohlen da die v3 seit nun fast zwei Jahren lediglich als Alpha Version verfügbar ist und bis heute als «unstable» gilt. Zudem sind die meisten Features noch gar nicht oder nicht richtig implementiert.

Unsere Lösung: Ein leichtgewichtiger GraphQL-Client im Frontend

Um das Feature umzusetzen, haben wir ein JavaScript-Snippet entwickelt, das wir in jeder gewünschten Landingpage oder übergeordnete «Tech-Page» einbinden können. Der Code wird dabei in die jeweilige Seite unter den «Page Properties» im Tab «Script» eingefügt:

JavaScript-Snippet

Der Code folgt beim Aufruf der jeweiligen Seite dem folgendem Ablauf:

  1. holt einmalig alle Seiten aus der GraphQL-API (Standartlimit bis 1000 gesetzt),
  2. filtert ausschliesslich jene, deren Pfad unterhalb des automatisch erkannten Seitenpfads liegt (also unterhalb der aktuell geöffneten Seite),
  3. entfernt die Landingpage selbst aus der Ausgabe,
  4. rendert das Ergebnis als übersichtliche Link-Liste mit dem bereits eingebauten links-list-Styling von Wiki.js.

In der wiki-Seite selbst wird dann der gerenderte HTML-content innerhalb des
<div id="pageTree">Loading pages …</div> mit dem generierten Menu ersetzt.

Verwendung HTML-Tag

Die Page kann dabei beliebig gestaltet oder erweitert werden, wichtig ist nur, dass das erwähnte Div am gewünschten Ort innerhalb der Seite platziert wird, wo die dynamische Sub-Übersicht gewünscht ist.

JavaScript Code

<script>
(() => {
  const debug = false;               // auf true setzen, falls etwas nicht wie gewünscht funktioniert.
  const MAX_ATTEMPTS = 30;           // bis zu 30 × 200 ms auf #pageTree warten (timeout)
  let attempts = 0;

  const log   = (...a) => debug && console.log(...a);
  const warn  = (...a) => debug && console.warn(...a);
  const error = (...a) => debug && console.error(...a);

  function init() {
    const container = document.getElementById('pageTree');
    if (!container) {
      attempts++;
      warn(`#pageTree not found, attempt ${attempts}`);
      if (attempts < MAX_ATTEMPTS) return setTimeout(init, 200);
      error('Gave up waiting for #pageTree');
      return;
    }

    /** Pfad der aktuellen Seite ohne führenden Locale-Teil */
    const selfPath = location.pathname
      .replace(/^\/[^/]+\//, '')     // z. B.  /en/  entfernen
      .replace(/^\/|\/$/g, '');      // führenden / und trailing / entfernen

    /** Präfix, das alle Kinder gemeinsam haben (selfPath + "/") */
    const childPrefix = selfPath.endsWith('/') ? selfPath : selfPath + '/';

    log('[Wiki.js] selfPath:', selfPath);
    log('[Wiki.js] childPrefix:', childPrefix);

    const query = `
      query {
        pages {
          list(limit: 1000, orderBy: PATH, orderByDirection: ASC) {
            path
            title
            locale
          }
        }
      }`;

    fetch('/graphql', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ query })
    })
      .then(r => r.json())
      .then(({ data }) => {
        if (!data?.pages?.list) {
          container.textContent = 'API-Fehler: keine Seitendaten.';
          error('pages.list fehlt im Response', data);
          return;
        }

        const pages = data.pages.list
          .filter(p => p.path.startsWith(childPrefix)) // nur echte Unterseiten
          .filter(p => p.path !== selfPath);           // Landingpage selbst raus

        log('[Wiki.js] gefilterte Seiten:', pages);

        if (!pages.length) {
          container.textContent = 'Keine Unterseiten vorhanden.';
          return;
        }

        const listHTML = pages.map(
          p => `<li><a href="/${p.locale}/${p.path}">${p.title}</a></li>`
        ).join('');

        container.innerHTML = `<ul class="links-list">${listHTML}</ul>`;
      })
      .catch(err => {
        container.textContent = 'Fehler beim Laden der Seitenliste.';
        error(err);
      });
  }

  init();
})();
</script>

Was passiert im Detail?

Nachfolgend haben wir für jene, die sich für JavaScript interessieren oder einfach genauer wissen möchten, was Zeile für Zeile genau passiert, das Ganze auf die einzelnen Zeilen aufgeschlüsselt. (Die Zeilennummern beziehen sich auf das komplette Snippet, inklusive <script>-Tag.)

Zeile(n)CodeTechnische Erklärung
1<script>Öffnet einen HTML-Script-Block – der Browser interpretiert alles bis </script> als JavaScript.
2(() => {Start eines IIFE (Immediately Invoked Function Expression). Dadurch liegt der gesamte Code in einem eigenen Scope; globale Namens­kollisionen werden vermieden.
3const debug = true;Schalter für ausführliche Konsolen­ausgabe. Bei false werden alle Log-Aufrufe unterdrückt.
4const MAX_ATTEMPTS = 30;Maximale Anzahl Wiederholungen beim Polling auf das DOM-Element.
5let attempts = 0;Zähler für die bereits erfolgten Polling-Versuche.
7 – 9const log…warn…error…Drei Arrow Functions, die jeweils nur dann auf console.* zugreifen, wenn debug aktiv ist. So spart man if-Blöcke in der Logik darunter.
11function init() {Hauptfunktion. Sie wird wiederholt aufgerufen, bis das Ziel-Element vorhanden ist.
12const container = document.getElementById('pageTree');Sucht das DIV, in dem später die Linkliste gerendert wird.
13 – 18if (!container) { … }Polling-Logik:
• Zähler erhöhen
• Warnung loggen
• Wenn attempts < MAX_ATTEMPTS, mit setTimeout(init, 200) nach 200 ms erneut prüfen.
• Nach dem Limit Abbruch mit Fehlermeldung.
22 – 24const selfPath = location.pathname.replace…Ermittelt den Pfad der aktuellen Wiki-Seite, entfernt dabei:
1. die Sprachpräfix-Sektion (Regex ^\/[^\/]+\/)
2. evtl. führenden bzw. abschliessenden Slash.
27const childPrefix = selfPath.endsWith('/') ? selfPath : selfPath + '/';Stellt sicher, dass der Child-Präfix immer mit / endet. Beispiel: 07-internal-it/it-services/.
29 – 30log('[Wiki.js] selfPath:' …)Debug-Ausgabe des aktuellen Pfades und des sub-Präfixes.
32 – 41const query = \…`;`Template Literal mit GraphQL-Query.
pages.list liefert bis zu 1000 Seiten.
• Sortiert nach Pfad, damit childs automatisch gruppiert sind.
43 – 47fetch('/graphql', { … })Stellt einen HTTP-POST an den Wiki.js-GraphQL-Endpunkt. Header Content-Type: application/json ist Pflicht.
48}).then(r => r.json())Erster Promise-Schritt: Die Antwort wird per Response.json() geparst.
49.then(({ data }) => {Zweiter Promise-Schritt: ES6-Destrukturierung, um data sofort herauszuziehen.
50 – 54if (!data?.pages?.list) { … }Prüfung auf Existenz der erwarteten Struktur unter Nutzung von Optional Chaining (?.). Bei Fehler: Meldung für den Benutzer und Logging.
56 – 58const pages = data.pages.list.filter…Zweistufiger Filter:
1. startsWith(childPrefix) → nur echte Unterseiten
2. !== selfPath → eigene Seite ausschliessen.
60log('[Wiki.js] gefilterte Seiten:', pages);Debug-Ausgabe des Ergebnisses nach dem Filter.
62 – 65if (!pages.length) { … }Fehlermeldung, falls keine Unterseiten existieren (z. B. nach Migration).
67 – 69const listHTML = pages.map(p => `<li>…`).join('');Baut für jede Unterseite ein <li><a …></a></li> und verbindet alles zu einem HTML-String.
71container.innerHTML = \<ul class=»links-list»>${listHTML}</ul>`;`Rendert die Linkliste. Die Klasse links-list sorgt für das card-artige Styling, das Wiki.js mitliefert.
73 – 76.catch(err => { … })Fehlerbehandlung der Fetch-Kette. Zeigt nutzerfreundliche Meldung und loggt das Exception-Objekt.
79init();Erster Aufruf der Init-Funktion – löst das Polling aus.
80})();Schliesst das IIFE und ruft es sofort auf.
81</script>Ende des Script-Blocks.

Kleiner Zusatz: Alphabetisches Sortieren nach «Page-Titel» anstatt Pfad

Im Standard GraphQL-Respond sind die jeweiligen Pages nach dem Pfad (PATH URL) sortiert. Diese kann sich aber vom eigentlichen Titel unterscheiden und so kann es auch Sinn machen, die Seiten nach ihrem Titel alphabetisch zu sortieren.

Dafür wird im JavaScript-Code noch vor dem Rendern der Liste const listHTML = pages.map (Zeile 67) das Array der Seiten mit der Funktion sort() und localeCompare() nachsortiert. Der benötigte Code sieht so aus:

pages.sort((a, b) => a.title.localeCompare(b.title));

Die nötige Änderung, respektive Ergänzung im Code müsste also wie folgt aussehen:

pages.sort((a, b) => a.title.localeCompare(b.title));

const listHTML = pages.map(
  p => `<li><a href="/${p.locale}/${p.path}">${p.title}</a></li>`
).join('');

Praxiserfahrungen nach der Integration

Nach wenigen Minuten ist das Snippet in mehreren Bereichen aktiv. Seitdem

  • erscheinen neue Dokumente sofort in der Übersicht,
  • entfällt die manuelle Pflege der Landingpages / Subpages Übersicht,
  • bleibt die Navigation auch bei tiefen Verschachtelungen konsistent.

Gleichzeitig ist das Skript so schlank, dass es künftige Migrationen bis v3 (sollte diese Version dann doch noch irgendwann erscheinen) nicht behindert. Bis dahin sichert es die Funktionsfähigkeit unseres Wikis mit minimalem Aufwand.

Beispiel Table of Content

Der folgende Screenshot zeigt ein Beispiel, bei dem eine alphabetische Sortierung nach dem Page-Namen mehr Sinn machen würde.

Fazit

Ein fehlendes Kernfeature muss nicht zwangsläufig auf das nächste Major-Upgrade warten. Mit einem präzisen Blick in die GraphQL-API und wenigen Zeilen JavaScript konnten wir unsere Dokumentationsqualität deutlich erhöhen. Wer ebenfalls auf Wiki.js v2 setzt, kann das Snippet ohne weitere Abhängigkeiten übernehmen. Fragen oder Verbesserungs­vorschläge bitte an info@swissmakers.ch, wir tauschen uns gerne aus. Für Unterstützung bei der Installation und Konfiguration von Wiki.js kontaktieren Sie uns ungeniert für ein Beratungsgespräch.

Foto des Autors

Michael Reber

Jahrelange Erfahrung in Linux, Security, SIEM und Private Cloud

Hinterlassen Sie einen Kommentar

1 × zwei =

de_CH