Tellerrand

Ein Magazin mit Nuxt-Content

Website Monitoring Magazin

Da man um das Thema Monitoring einiges erklären kann, hat es sich eigentlich angeboten ein Magazin an die Koality-Seite zu hängen. Wir hätten dafür ein CMS aufsetzen können oder ein Headless-CMS Service nutzen können, aber wir haben uns dann für https://content.nuxtjs.org/ entschieden. Sehr zur Freude meinerseits, so konnte man die Grenzen des Plugins ausloten.

Pros

  • Markdown macht es einfach Artikel zu pflegen
  • Man kann Vue Komponenten verwenden
  • Wenn Markdown nicht reicht kann man HTML verwenden
  • Alle Artikel werden mit GIT versioniert

Cons

  • Um neue Artikel zu veröffentlichen, muss man die gesamte Applikation neu generieren
  • Verwendet man lokale Vue Komponenten, muss man das Live-Editing deaktivieren

Anforderungen

Eigentlich regulär wie bei einem Blog auch, eine Übersicht der Artikel, Kategorien und eine Autorenbox und Seite. Oben drauf wäre es noch ganz nett, zu bestimmen welche Artikel in der Produktivseite sichtbar sind. Da Nuxt-Content solche Funktionen aber nicht out of the Box liefert, muss man selbst Hand anlegen.

Verzeichnisstruktur

Die Struktur der Nuxt Pages ist relativ überschaubar:

Wir haben eine index.vue, das ist die Startseite des Magazins. Des Weiteren haben wir eine _.vue, die mehr oder minder alle URLs abfängt, die mit dem Magazin in Verbindung stehen. Innerhalb der Page wird dann entschieden, ob es sich um eine Kategorieübersicht handelt oder um einen Artikel.

Zu guter letzt haben wir noch das Verzeichnis /a in dem eine dynamische Page liegt, die dann die Übersicht der Artikel des jeweiligen Autors bereitstellt.

Im eigentlichen Content Verzeichnis (das man im Projekt-Root anlegen muss, wenn man Nuxt-Content installiert), befinden sich dann die Artikel. Da die Autoren-Infos auch über Markdown gepflegt werden, landen alle Artikel in einem eignen Verzeichnis, gruppiert über die jeweiligen Kategorien.

Aufbau einer Nuxt-Content Markdown Datei

Wer schon einmal mit dem CMS Kirby gearbeitet hat, wird den grundlegenden Aufbau wieder erkennen.

---
title: Eine tolle Headline
description: Lorem Ipsum 
image: articles/image.jpg
author: nils
published: true
---
    
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Laboriosam, quidem, sed. Aperiam atque, autem cumque delectus eos esse hic illum ipsa laudantium molestias nihil nobis non placeat provident quae, unde.

Oberhalb des eigentlichen Inhalts befindet sich der Block mit den Meta Informationen. Dort können wir eigene Variablen, Arrays und Objekte abgelegen, die man dann in den Pages die auf this.$content() zugreifen, wieder auslesen kann.

Möchte man ein Produktkatalog mit Preisen erstellen, kann man die Meta Informationen um Preise erweitern. Es gibt eigentlich keine wirklichen Limits, solange man es vue-technisch auch umsetzen kann.

Fangen wir einmal an

Ich persönlich fang gerne mit der Artikelseite selbst an, weil die relativ schnell gemacht ist. Wenn sie fertig ist, können die Redakteure schon mit dem Schreiben beginnen.

Da wir aber die Artikel über Kategorien strukturieren wollen, die ebenfalls eigene Übersichtseiten besitzen, wird es etwas kniffliger. Den Aufwand muss man sich nicht machen, wenn es um eine Handvoll Artikel geht, aber in unserem Fall wird das Magazin wachsen.

Catch me if you can

Damit das ganze aber nicht in Arbeit ausartet, in dem man für jede Kategorie eine extra Seite erstellt (was eben auch unflexibel ist), liegt dort eine CatchAll Page _.vue. Alles was man über die URL koality.io/de/magazin/articles/[name] aufruft wird von dieser Page abgefangen.

<template>
  <div>
    <div
      v-if="!$fetchState.pending && doc"
      class="my-16"
    >
      <MagazineCategory
        v-if="doc && doc.type"
        :key="doc.slug"
        :content="doc"
      />
      <MagazineDetails
        v-else
        :content="doc"
      />
    </div>
    <div v-if="fetchError">
      <p class="mb-3">
        Der gesuchte Artikel wurde nicht gefunden
      </p>
      <NuxtLink to="/de/magazin" class="underline" exact>
        Zurück zur Magazin Startseite
      </NuxtLink>
    </div>
  </div>
</template>

<script>
import MagazineDetails from '@/components/others/MagazineDetails/MagazineDetails'
import MagazineCategory from '@/components/others/MagazineCategory/MagazineCategory'

export default {
  name: 'PageMagazine',
  components: { MagazineCategory, MagazineDetails },
  async fetch () {
    const { params } = this.$route
    try {
      const path = params.pathMatch || 'index'

      const [doc] = await this.$content('/articles', { deep: true })
        .where({ path: `/${path.replace(/\/$/, '')}` })
        .fetch()

      if (!doc) {
        this.fetchError = true
        if (process.server) {
          this.$nuxt.context.res.statusCode = 404
        }
        throw new Error('Article not found')
      }

      this.doc = doc
    } catch (error) {
      console.error(error)
    }
  },

  data () {
    return {
      doc: null,
      fetchError: false
    }
  }
}
</script>

Die Page Komponente durchsucht dabei alle Markdown Dateien im content/articles/ Verzeichnis und kann dann über eine Variable type innerhalb der Markdown Dateien entscheiden, was ein Artikel und was eine Kategorieübersicht ist.

Aus dem Grund existieren die Kategorie Markdown Dateien, neben den gleichlautenden Verzeichnissen.

---
type: category
title: Über uns
description: Alles über koality.io, was man so wissen muss.
color: bg-categories-content
image: testimage.jpg
---

Die Dateien haben neben der Konfigurationsmöglichkeit auch den Vorteil, das man alle gesetzten Kategorien sammeln kann, um damit eine Kategorie Navigation zu erstellen. Dazu aber später mehr.

Ein Artikel soll es sein

Wie schon angedeutet ist die Artikelseite selbst, eine Komponente. Diese ist so weit auch kein großes Hexenwerk, im Kern würde das folgende Snippet schon ausreichen.

<nuxt-content :document="content" class="magazine" />

Ob das schon ausreicht, hängt vom gewünschten Layout ab, der Wrapper stellt halt den eigentlichen Body der Markdown Datei dar. In unserem Fall gibt es da einen Teaser, eine Sidebar mit dem TOC des Artikels, Autoren Box und ein Verweis auf den neusten Artikel. Dazu kommen dann noch Vue Komponenten die man innerhalb des Markdown Artikels verwenden möchte.

Integrierte Vue Komponenten

Durch den Mix mit Markdown und Vue Komponenten, kann man eine Art ContentBuilder etablieren. Eine schöne Sache damit kann man etwas komplexere Gebilde, einfach in den Artikel einbinden. Das macht es den Redakteuren einfacher.

_Hier nur ein Hinweis. Man kann die Komponenten global wie auch lokal einbinden. Wer letzteres verwendet, muss aktuell aber die Live Edit Funktion deaktivieren. _

Die Handhabung der Komponenten unterscheidet sich dabei nicht, wer möchte, kann ja Nuxt-Content als Dokumentationstool für seine Vue Komponenten verwenden.

Kategorisier' mich

Die Kategorieübersicht wird wie ähnlich wie die Artikeldetails über die CatchAll Page angesteuert. Wichtig in dem Fall sind aber noch Meta Informationen wie die Farbe und SEO Informationen.

<template>
  <div>
    <h1 class="text-3xl font-medium mb-6 text-black leading-tight">
      {{ content.title }} <br>
      <span class="font-light text-2xl">Website Monitoring Magazin</span>
    </h1>
    <ArticleCollection
      :limit="100"
      :path="content.path"
      class="mb-12 md:mb-24"
    />
    <MagazineCategories
      class="mb-12 md:mb-24"
      :active-category="content.title"
    />
  </div>
</template>

Den Content bezieht diese Komponente, ebenfalls über die Props.

Die Startseite sieht in etwa ähnlich aus, nur hat diese noch keine festgelegte Kategorie.

ArticleCollection

Mit dieser Komponente werden die Vorschauen zu den Artikeln erstellt, optional kann über die Kategorie oder den Autor gefiltert werden. Ebenfalls ist es möglich über das Datum die aktuellsten Artikel zu sammeln.

<template>
  <div v-if="!$fetchState.pending">
    <div 
      v-if="asideContent && articles.length" 
      class="font-medium text-black text-lg mb-2"
     >
      Neuester Artikel
    </div>
    <div v-if="error">
      Leiderkeine Artikel verfügbar
    </div>
    <div 
      v-else 
      :class="[!asideContent ? 'grid-threeCol' : null]"
    >
      <ArticlePreview
        v-for="article in articles"
        :key="article.slug"
        :article="article"
        :aside-content="asideContent"
      />
    </div>
  </div>
</template>

<script>
import ArticlePreview from '@/components/notice/ArticlePreview/ArticlePreview'

export default {
  name: 'ArticleCollection',
  components: { ArticlePreview },
  props: {
    limit: {
      type: Number,
      default: 3
    },
    asideContent: {
      type: Boolean,
      default: false
    },
    authorArticles: {
      type: Boolean,
      default: false
    },
    sortDirection: {
      type: String,
      default: 'desc'
    },
    path: {
      type: String,
      default: '/articles'
    },
    activeArticle: {
      type: String,
      default: ''
    }
  },
  async fetch () {
    this.error = false
    const baseQuery = () => ({ type: { $ne: 'category' } })
    const publishQuery = () => process.env.NODE_ENV !== 'production' ? ({ published: true }) : null
    const authorQuery = () => this.authorArticles ? ({ author: { $eq: this.$route.params.author } }) : null

    try {
      this.articles = await this.$content(this.path, { deep: true })
        .only(['title', 'description', 'slug', 'type', 'image', 'dir', 'path', 'author'])
        .where({ ...baseQuery(), ...publishQuery(), ...authorQuery() })
        .sortBy('createdAt', this.sortDirection)
        .limit(this.asideContent ? 1 : this.limit)
        .fetch()
    } catch (error) {
      console.error(error)
      this.error = true
    }
  },

  data () {
    return {
      articles: null,
      error: false
    }
  }
}
</script>

Mittels der Query-Language kann man dabei recht genau definieren, welche Artikel man auslesen möchte.

Die ArticlePreview Komponente stellt am Ende die Preview des Artikels dar, ich denke, dazu braucht man nicht viel zu erklären.

MagazineCategories

Die MagazineCategories Komponente stellt die Navigation zu den vorhandenen Kategorieseiten bereit.

<template>
  <div v-if="!$fetchState.pending">
    <div class="text-3xl font-medium mb-6 text-black">
      <template v-if="activeCategory">
        Weitere
      </template>
      Kategorien
    </div>
    <nav class="grid-threeCol">
      <n-link
        v-for="category in categories"
        :key="category.slug"
        :class="[category.color]"
        class="shadow-xl transition-shadow duration-300 ease-in hover:shadow font-medium px-4 py-3 block"
        :to="`/de/magazin${category.path}`"
        exact
      >
        {{ category.title }}
      </n-link>
    </nav>
  </div>
</template>

<script>
export default {
  name: 'MagazineCategories',

  props: {
    activeCategory: {
      type: String,
      default: ''
    }
  },

  async fetch () {
    const categoryRaw = await this.$content('/articles').fetch()
    this.categories = categoryRaw
      .filter(val => val.type === 'category' && val.title !== this.activeCategory)
      .map(val => ({ color: val.color || '', title: val.title, path: val.path, slug: val.slug }))
  },

  data () {
    return {
      categories: null
    }
  }
}
</script>

Der "größte" Zauber besteht hier, das die Komponente in der content/articles/ Ebene alle Markdown Dateien einsammelt und zur Sicherheit noch explizit nach dem type=category sucht. Regulär sollte sich auf der Ebene noch kein Artikel aufhalten.

Die gesammelten Meta-Informationen aus den Markdown Dateien werden dann in ein neues Array übergeben. Und daraus resultiert denn die Kategorienavigation.

Für einen Redakteur ist es dabei relativ einfach bestehende Kategorien zu pflegen oder zu erweitern, ohne dafür nur eine Zeile Vue Code zu ändern. Alles passiert über Markdown und die dortige Verzeichnisstruktur.

Gib dem Autor etwas Raum

Da die Artikel nicht alleine von einer Person geschrieben werden und wir das auch zeigen wollen, braucht man eine Autoren-Box und die Möglichkeit eine Übersicht über alle Artikel eines Autors darzustellen.

Die Box wäre erst mal nichts Kompliziertes, da die Infos des Autors auch einfach in die Meta Informationen des Artikels ablegen könnte.

---
title: lorem
description: lorem ipsum
image: articles/image.jpg
author:
    name: Nils
    description: Lorem Ipsum
    image: authors/nils.jpg
...
---

Das wäre durchaus möglich, aber wäre auf Dauer sicherlich lästig.

Einfacher wäre es so:

---
title: lorem
description: lorem ipsum
image: articles/image.jpg
author: nils
...
---

Das Schöne an Nuxt-Content, man kann es wie eine NoSQL Datenbank verwenden. Also habe ich im Content-Verzeichnis ein weiteres Unterverzeichnis namens authors/ platziert.

Und dort befinden sich, ja genau richtig geraten, wieder Markdown Dateien dieses mal mit den Informationen des Autors.

---
name: Nils Langner
function: Gründer koality.io
key: nils
intro: "Nils ist seit über einem Jahrzehnt fester Bestandteil der Webentwicklerszene. 2017 gründete er Leankoala und 2020 dann koality.io. 
In unserem Magazin kümmert er sich um allen Themen rund um das Agenturleben, Technik und Monitoring."
description: Nils ist einer der Gründer von koality.io und kümmert sich hier im Magazin um die Themen Qualität und Monitoring.
image: nils.jpg
---

Im Artikel liegt dann in der Sidebar eine Autorenbox, die dann die Informationen bereitstellt. Da im Artikel ja der Autor notiert wurde, ist es einfach danach zu suchen.

<template>
  <div v-if="!$fetchState.pending">
    <n-link
      v-if="authorDetails.image"
      class="block"
      :to="`/de/magazin/a/${author}`"
      exact
    >
      <LazyImage
        :image-kit="true"
        :image="`koality/magazine/authors/${authorDetails.image}`"
        :sizes="[300, 600, 900]"
        custom-transform="ar-1-1,fo-auto"
        class="mb-4"
      />
    </n-link>
    <div>
      <n-link
        class="font-medium text-black text-lg mb-2"
        :to="`/de/magazin/a/${author}`"
        exact
      >
        {{ authorDetails.name }}
      </n-link>
      <p class="font-light leading-relaxed">
        {{ authorDetails.description }}
      </p>
    </div>
  </div>
</template>

<script>
import LazyImage from '@/components/basis/LazyImage/LazyImage'

export default {
  name: 'ArticleAuthor',
  components: { LazyImage },
  props: {
    author: {
      type: String,
      default: ''
    }
  },

  async fetch () {
    this.authorDetails = await this.$content(`/authors/${this.author}`).fetch()
  },

  data () {
    return {
      authorDetails: null
    }
  }
}
</script>

Wie man sieht, ist der eigentliche fetch() ziemlich kurz, da man an dieser Stelle gezielt nach einem Dateinamen sucht.

Die Autorenseite selbst folgt dem gleichen Muster wie eine Kategorieseite, nur das wir hier noch einmal die Langform der Autoreninfos als zusätzlichen Block bereitstellen.

<template>
  <div class="my-16">
    <div v-if="!$fetchState.pending && authorDetails">
      <h1 class="text-3xl font-medium mb-6 text-black leading-tight">
        {{ authorDetails.name }} <br>
        <span class="font-light text-2xl">{{ authorDetails.function }}</span>
      </h1>
      <div class="md:flex mb-10 md:mb-20">
        <LazyImage
          v-if="authorDetails.image"
          :image-kit="true"
          :image="`koality/magazine/authors/${authorDetails.image}`"
          :sizes="[300, 600, 900, 1200, 1600]"
          custom-transform="ar-16-9,fo-auto"
          class="md:w-2/5 mb-6 md:mb-0"
        />
        <div class="md:w-3/5 md:pl-8 font-light text-lg leading-relaxed">
          <p>
            {{ authorDetails.intro }}
          </p>
        </div>
      </div>
      <div class="font-medium text-black text-3xl mb-6 leading-tight">
        Alle Artikel von {{ authorDetails.name }} <br>
        <span class="font-light text-2xl">Website Monitoring Magazin</span>
      </div>
      <ArticleCollection
        :author-articles="true"
        :limit="100"
      />
    </div>
    <div v-if="$fetchState.error">
      <p class="mb-3">
        Der betreffende Author wurde nicht gefunden.
      </p>
      <NuxtLink to="/de/magazin" class="underline" exact>
        Zurück zur Magazin Startseite
      </NuxtLink>
    </div>
  </div>
</template>

<script>
import ArticleCollection from '@/components/others/ArticleCollection/ArticleCollection'
import LazyImage from '@/components/basis/LazyImage/LazyImage'

export default {
  name: 'IndexVue',
  components: { LazyImage, ArticleCollection },
  async fetch () {
    try {
      this.authorDetails = await this.$content(`/authors/${this.$route.params.author}`).fetch()
    } catch (error) {
      console.error(error)
      if (process.server) {
        this.$nuxt.context.res.statusCode = 404
      }
      throw new Error('Author not found')
    }
  },
  data () {
    return {
      authorDetails: null
    }
  }
}
</script>

Finish

Wie man gesehen hat, kommt man mit Nuxt-Content und den gegebenen Möglichkeiten recht weit. Dank der Query-Language kann man die Markdown Dateien wie eine NoSQL DB verwenden.