Nachdem ich vor ein paar Tagen mein neues Wiki online stellte und komplett zufrieden war, schaute ich auch mal auf der Website pagespeed.web.dev nach, wie viele Punkte das Wiki bekommt.

Schock: Es waren nur 52 Punkte (mobile Ansicht) und 71 Punkte (Desktop). Das ist weit weniger, als bei meinem alten Wiki mit 74 Punkten (mobil) und 98 Punkten (Desktop).

Da das verwendete Theme mit dem Namen “Docsy” von Google stammt (das habe ich erst nach der Entscheidung dafür bemerkt), nahm ich an, dass auch die Punktezahl bei dem Test entsprechend hoch sein sollte.

Auch die Website des Themes erreicht dort nur 48 Punkte (mobil) und immerhin 96 Punkte (Desktop).

Unschön gemachte Offlinesuche

Dann haben wir (Vri und ich) viel ausprobiert und haben herausgefunden, dass es hauptsächlich an der verwendeten Offlinesuche des Themes liegt. Schaltet man diese ab, dann steigt das Ergebnis auf 89 Punkte (mobile Ansicht) und 100 Punkte (Desktop).

Der Grund: Die Index-Datei für die Offlinesuche wird bei jedem Seitenaufruf automatisch abgerufen und ausgewertet. Bei einer ungepackten Größe von ca. 800 KiB der json-Datei ist das nicht unerheblich.

Unschönes CSS

Aber auch ein “Preload” eines CSS ist nicht gerade schön gemacht. Dieses bewirkt, dass es relativ lange dauert, bevor überhaupt Inhalte im Browser angezeigt werden.

Was nun?

Mir ist grundsätzlich egal, wie viele Menschen auf meinen Webseiten gucken und ich habe deshalb dazu auch keine Auswertungen. Mir ist jedoch wichtig, dass man die Inhalte findet, wenn man danach sucht. Und meines Wissens verweisen Suchmaschinen weniger gerne auf Webseiten, die relativ langsam sind. (Stimmt dieses alte Halbwissen überhaupt noch?)

Abgesehen davon soll das neue Wiki auch auf älterer Hardware (Desktop und Smartphone) und langsamen Verbindungen gut funktionieren.

Optimierungen

CSS ohne Preload

Um das Preload aus dem CSS herauszubekommen, kopiert man die Datei themes/docsy/layouts/partials/head-css.html nach layouts/partials/head-css.html und ändert die unteren Zeilen von:

<link rel="preload" href="{{ $css.RelPermalink }}" as="style">
<link href="{{ $css.RelPermalink }}" rel="stylesheet" integrity="{{ $css.Data.integrity }}">

Nach:

<link href="{{ $css.RelPermalink }}" rel="stylesheet">
<link href="{{ $css.RelPermalink }}" rel="stylesheet" integrity="{{ $css.Data.integrity }}">

Update
25.03.2022: Jemand hat mich darauf hingewiesen, dass diese beiden geänderten Zeilen zwei mal das selbe einbinden. Ich habe daher die letzte mit integrity= gelöscht.

Suche nur auf bestimmten Seiten

Eine Möglichkeit ist es, die eingebaute Offlinesuche des Themes nicht mehr auf allen Seiten im Wiki anzuzeigen, sondern nur auf einer eigenen Suchseite.

Ich bin kein Profi bei sowas, habe mir den Quelltext des Themes angeschaut und keine Stelle gefunden, das zu tun und diese Möglichkeit daher verworfen. Aber schlaue Menschen können hier bestimmt mehr und könnten das für sich so umsetzen. Daher sei diese Möglichkeit genannt.

Andere Implementierung für die Suche

Eine andere Möglichkeit ist, die eingebaute Suchfunktion zu deaktivieren und selbst eine zu implementieren. Ich habe mich für die Methode entschieden, die in diesem Gist beschrieben wird. (In der Hugo-Dokumentation wird u. a. darauf verwiesen.

Der Code im Gist steht unter der MIT-Lizenz zur Verfügung, siehe hier.

Zu erstellende Dateien und Verzeichnisse

  1. In die Datei config.toml:

    [outputs]
    home = [ "HTML", "JSON" ]

  2. In die Datei content/de/search/_index.md

    ---
    title: "Suche"
    sitemap:
      priority : 0.1
    layout: "search"
    weight: 20
    menu:
      main:
        weight: 50
    ---

  3. In die Datei layouts/_default/index.json:

    {{- $.Scratch.Add "index" slice -}}
    {{- range .Site.RegularPages -}}
        {{- $.Scratch.Add "index" (dict "title" .Title "tags" .Params.tags "categories" .Params.categories "contents" .Plain "permalink" .Permalink) -}}
    {{- end -}}
    {{- $.Scratch.Get "index" | jsonify -}}

Beim nächsten Bau der Website wird die Datei /index.json automatisch erstellt.

  1. In die Datei layouts/_default/search.html (abgeändert vom Original, da es nicht funktioniert hat):

    {{ define "main" }}
    <script src="/js/offsearch/jquery.min.js"></script>
    <script src="/js/offsearch/fuse.min.js"></script>
    <script src="/js/offsearch/jquery.mark.min.js"></script>
    <script src="/js/offsearch/offsearch.js"></script>
    
    <section class="offsearch">
    <noscript><p id="offsearch-noscript">Diese Suchfunktion benötigt JavaScript.</p></noscript>
      <div class="offsearch" >
        <form action="{{ "search" | absURL }}">
          <input id="search-query" name="s" placeholder="Wiki durchsuchen…" aria-label="Search" />
        </form>
        <div id="search-results"></div>
      </div>
    </section>
    <!-- this template is sucked in by search.js and appended to the search-results div above. So editing here will adjust style -->
    <script id="search-result-template" type="text/x-js-template">
        <div id="summary-${key}">
          <h4><a href="${link}">${title}</a></h4>
          <p>${snippet}</p>
          ${ isset tags }<p>Tags: ${tags}</p>${ end }
          ${ isset categories }<p>Categories: ${categories}</p>${ end }
        </div>
    </script>
    {{ end }}

  2. Ich habe das Verzeichnis static/js/offsearch/ erstellt, wo später die notwendigen JavaScript-Dateien bereitgestellt werden.

  3. JavaScript selbst hosten: Ich habe die in der Originaldatei search.html eingebundenen Dateien ins Verzeichnis static/js/offsearch/ heruntergeladen, diese entsprechend lokal eingebunden und hoste sie nun selbst.

  4. JavaScript für die Suchfunktion Diese Datei kommt nach static/js/offsearch/offsearch.js:

    summaryInclude=60;
    var fuseOptions = {
    shouldSort: true,
    includeMatches: true,
    threshold: 0.0,
    tokenize:true,
    location: 0,
    distance: 100,
    maxPatternLength: 32,
    minMatchCharLength: 1,
    keys: [
        {name:"title",weight:0.8},
        {name:"contents",weight:0.5},
        {name:"tags",weight:0.3},
        {name:"categories",weight:0.3}
    ]
    };
    
    
    var searchQuery = param("s");
    if(searchQuery){
    $("#search-query").val(searchQuery);
    executeSearch(searchQuery);
    }else {
    $('#search-results').append("<p>Please enter a word or phrase above</p>");
    }
    
    
    
    function executeSearch(searchQuery){
    $.getJSON( "/index.json", function( data ) {
        var pages = data;
        var fuse = new Fuse(pages, fuseOptions);
        var result = fuse.search(searchQuery);
        console.log({"matches":result});
        if(result.length > 0){
        populateResults(result);
        }else{
        $('#search-results').append("<p>No matches found</p>");
        }
    });
    }
    
    function populateResults(result){
    $.each(result,function(key,value){
        var contents= value.item.contents;
        var snippet = "";
        var snippetHighlights=[];
        var tags =[];
        if( fuseOptions.tokenize ){
        snippetHighlights.push(searchQuery);
        }else{
        $.each(value.matches,function(matchKey,mvalue){
            if(mvalue.key == "tags" || mvalue.key == "categories" ){
            snippetHighlights.push(mvalue.value);
            }else if(mvalue.key == "contents"){
            start = mvalue.indices[0][0]-summaryInclude>0?mvalue.indices[0][0]-summaryInclude:0;
            end = mvalue.indices[0][1]+summaryInclude<contents.length?mvalue.indices[0][1]+summaryInclude:contents.length;
            snippet += contents.substring(start,end);
            snippetHighlights.push(mvalue.value.substring(mvalue.indices[0][0],mvalue.indices[0][1]-mvalue.indices[0][0]+1));
            }
        });
        }
    
        if(snippet.length<1){
        snippet += contents.substring(0,summaryInclude*2);
        }
        //pull template from hugo templarte definition
        var templateDefinition = $('#search-result-template').html();
        //replace values
        var output = render(templateDefinition,{key:key,title:value.item.title,link:value.item.permalink,tags:value.item.tags,categories:value.item.categories,snippet:snippet});
        $('#search-results').append(output);
    
        $.each(snippetHighlights,function(snipkey,snipvalue){
        $("#summary-"+key).mark(snipvalue);
        });
    
    });
    }
    
    function param(name) {
        return decodeURIComponent((location.search.split(name + '=')[1] || '').split('&')[0]).replace(/\+/g, ' ');
    }
    
    function render(templateString, data) {
    var conditionalMatches,conditionalPattern,copy;
    conditionalPattern = /\$\{\s*isset ([a-zA-Z]*) \s*\}(.*)\$\{\s*end\s*}/g;
    //since loop below depends on re.lastInxdex, we use a copy to capture any manipulations whilst inside the loop
    copy = templateString;
    while ((conditionalMatches = conditionalPattern.exec(templateString)) !== null) {
        if(data[conditionalMatches[1]]){
        //valid key, remove conditionals, leave contents.
        copy = copy.replace(conditionalMatches[0],conditionalMatches[2]);
        }else{
        //not valid, remove entire section
        copy = copy.replace(conditionalMatches[0],'');
        }
    }
    templateString = copy;
    //now any conditionals removed we can do simple substitution
    var key, find, re;
    for (key in data) {
        find = '\\$\\{\\s*' + key + '\\s*\\}';
        re = new RegExp(find, 'g');
        templateString = templateString.replace(re, data[key]);
    }
    return templateString;
    }
    
    /* MIT License
    
    Copyright 2022 Edward A. Webbinaro
    
    Permission is hereby granted, free of charge, to any person
    obtaining a copy of this software and associated documentation
    files (the "Software"), to deal in the Software without
    restriction, including without limitation the rights to
    use, copy, modify, merge, publish, distribute, sublicense,
    and/or sell copies of the Software, and to permit persons
    to whom the Software is furnished to do so, subject to
    the following conditions:
    
    The above copyright notice and this permission notice shall
    be included in all copies or substantial portions of the Software.
    
    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
    ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
    TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
    PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
    SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
    CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
    OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
    IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
    DEALINGS IN THE SOFTWARE. */

Noch etwas CSS für die neue Suchfunktion

Dieses CSS kommt in die Datei assets/css/_styles_project.scss:

.offsearch form {margin-bottom: 40px;}
.offsearch #search-query {width: 100%; padding: 10px;}
.offsearch #search-results p {hyphens: auto;}

Ergebnis – Suchfunktion auf einer einzelnen Seite

Die Suche findet man jetzt unter wiki.natenom.com/search/ und diese Seite ist immer rechts oben verlinkt.

Für die Zukunft ist geplant, diese als Suchfeld in der Navigation einzufügen.

Ich bin sehr zufrieden mit der neuen Suche. Im Vergleich zu der Suche im Blog ist sie extrem schnell und zeigt die Ergebnisse auch schön an.

Deshalb werde ich diese Suche in den nächsten Tagen vermutlich auch in meinem Blog einbauen. Dort dauert die Suche aktuell sehr lange und verursacht sehr viel CPU-Last, obwohl dort als auch im neuen Wiki fuse.js genutzt wird.

Neue Suche im neuen Wiki

Bei der Punktezahl erreicht das Wiki jetzt in 89 Punkte (mobil) (statt zuvor 52) und 100 Punkte (Desktop) (statt zuvor 71).

89 Punkte für die mobile Ansicht

100 Punkte für die Desktop Ansicht

Ursprünglich hatte ich die Sidebar links (Seitenverzeichnis) so eingestellt, dass man jeden Bereich einzeln aufklappen/zuklappen konnte. Dadurch wurden aber im fertigen HTML ungefähr 2000 Zeilen nur für die Sidebar eingefügt. Übersichtlicher wurde es dadurch aber nicht.

sidebar_menu_foldable = true

Deshalb habe ich die Sidebar jetzt so eingestellt, dass aufklappen/einklappen nicht mehr möglich ist. Es ist immer nur der aktuelle Bereich geöffnet.

Hier gibt es vom Theme mehrere Einstellungsmöglichkeiten im Bereich params in der Datei config.toml:

  • ui.ul_show – Gibt an, bis zu welcher Anzahl die Unterebenen des aktuellen Bereichs geöffnet dargestellt werden.
    • Hier mit ui.ul_show = 1

      ui.ul_show = 1

    • Hier mit ui.ul_show = 2

      ui.ul_show = 2

  • sidebar_menu_compact – Man kann auch alles immer offen einstellen. Aber das eignet sich wirklich nur für sehr kleine Websites:

    sidebar_menu_compact = false

Auch auf die generierten HTML-Dateien wirkt sich das aus. Circa 2000 Zeilen werden benötigt, wenn die Sidebar aufklappbar ist und “nur” noch ca. 1400, wenn die Sidebar nur im aktuellen Bereich geöffnet ist. (Ich hätte erwartet, dass es im letzteren Fall bei sehr kleinen Bereichen wie “Fotografie” nur ein paar Dutzend Zeilen wären.)

Dokumentation zur Sidebar

Zeit zum Rendern des Wiki

Beim Durchstöbern der Einstellungsmöglichkeiten zur Sidebar (siehe oben) habe ich auch die sidebar_cache_limit gefunden.

Aus der Dokumentation:

On large sites (default: > 2000 pages) the section menu is not generated for each page, but cached for the whole section. The HTML classes for marking the active menu item (and menu path) are then set using JS. You can adjust the limit for activating the cached section menu with the optional parameter .ui.sidebar_cache_limit.

In meinem Wiki gibt es aktuell 1333 Seiten und ich habe das Limit testweise auf 500 eingestellt:

sidebar_cache_limit = 500

Die Zeit, um mein gesamtes Wiki zu rendern verringert sich damit von circa 12 Sekunden auf circa 2 Sekunden.

Der Nachteil ist jedoch, dass die übergeordneten Seiten nicht mehr angezeigt werden, sobald man zu tief in den Unterebenen eines Bereichs ist.

Sidebar wird gecached und es ist nicht alles sichtbar.

Sidebar wird nicht gecached und es ist alles sichtbar.

Damit ich nicht in Zukunft von diesem Mechanismus überrascht werde, sobald das Wiki über 2000 Seiten hat, habe ich den Wert auf 5000 eingestellt.

Swap Fonts

Ich wurde darauf hingewiesen, dass in bei der Einbindung der selbst gehosteten Schriften im CSS noch einstellen kann, dass lokal vorhandene Systemschriften im Browser verwendet werden, solange die vom Server bereitgestellten Schriften noch nicht heruntergeladen wurden. Dadurch werden Inhalte früher dargestellt.

Pro @font-face {-Eintrag in der Datei assets/scss/_variables_project.scss kommt diese Zeile hinzu: 1

font-display: swap;

Gar keine systemfremden Schriften

Es ist auch möglich, die Verwendung der eingebundenen Schriften komplett zu unterbinden. Dazu trägt man in der Datei assets/scss/_variables_project.scss ein:

$td-enable-google-fonts: false;

Mir gefällt die verwendete Schriftart des Themes, daher nutze ich nicht die Möglichkeit zur Abschaltung.

Galerie hinzugefügt

Ich habe zum Wiki-Theme noch die hugo-shortcode-gallery hinzugefügt, die ich auch bereits hier im Blog verwende.

cd themes
git submodule add https://github.com/mfg92/hugo-shortcode-gallery.git

Änderungen in der Datei config.toml:

  • Aus theme = "docsy" wird theme = [ "docsy", "hugo-shortcode-gallery" ]

Dazu kommen diese Einträge in den Bereich params (die ich so auch im Blog verwende):

galleryShowExif = true
gallerylastRow = "nojustify"
gallerythumbnailResizeOptions = "450x450 q85 Lanczos"
gallerythumbnailHoverEffect = "enlarge"
galleryloadJQuery = true
galleryrowHeight = "150"
gallerysortOrder = "desc"
gallerypreviewType = "none"

Eine Beispielgalerie kann man hier ansehen: wiki.natenom.com/docs/sauerbraten/maps/kleinestadt/.