Beyond The Horizon

Ein Magazin mit Nuxt-Content

Website Monitoring Magazine

Since there is a lot to explain around monitoring, it actually made sense to add a magazine to the Koality site. We could have set up a CMS for it or used a headless CMS service, but we ended up choosing https://content.nuxtjs.org/. Much to my delight, this allowed us to test the limits of the plugin.

Pros

  • Markdown makes it easy to maintain articles
  • You can use Vue components
  • If Markdown is not enough you can use HTML
  • All articles are versioned with GIT

Cons

  • To publish new articles you have to regenerate the whole application
  • If you use local Vue components, you have to disable live editing

Requirements

Actually regular like a blog too, an overview of articles, categories and an author box and page. On top it would be quite nice to determine which articles are visible in the production page. But since Nuxt-Content doesn't provide such features out of the box, you have to do it yourself.

Directory structure

The structure of Nuxt Pages is relatively straightforward:

.

We have an index.vue, which is the home page of the magazine. Furthermore, we have a _.vue, which more or less intercepts all URLs related to the magazine. Inside the page we decide if it is a category overview or an article.

Last but not least we have the directory /a in which a dynamic page is located, which then provides the overview of the articles of the respective author.

In the actual content directory (which you have to create in the project root when you install Nuxt-Content), you will then find the articles. Since the author info is also maintained via Markdown, all articles end up in their own directory, grouped by category.

Structure of a Nuxt-Content Markdown file

If you have already worked with the CMS Kirby, you will recognize the basic structure.

---
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.

Above the actual content is the block with the meta information. There we can store our own variables, arrays and objects, which can then be read again in the pages that access this.$content().

If you want to create a product catalog with prices, you can add prices to the meta information. There are no real limits as long as you can implement it vue-technically.

Let's start

Personally, I like to start with the article page itself, because it's relatively quick to do. Once it's done, the editors can already start writing.

But since we want to structure the articles using categories, which also have their own overview pages, it gets a little trickier. You don't have to go to that trouble if you're talking about a handful of articles, but in our case, the magazine will grow.

Catch me if you can

So that the whole thing doesn't degenerate into work by creating an extra page for each category (which is also inflexible), there is a CatchAll Page _.vue. Everything you call with the URL koality.io/en/magazine/articles/[name] will be caught by this page.

<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>

The page component searches all Markdown files in the content/articles/ directory and can then decide what is an article and what is a category overview via a variable type within the Markdown files.

For this reason, the category Markdown files exist next to the directories with the same name.

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

The files have beside the configuration possibility also the advantage, that one can collect all set categories, in order to create with it a category navigation. But more about that later.

An article should be

As already indicated, the article page itself is a component. This is so far also no big witchcraft, in the core the following Snippet would already suffice.

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

Whether this is sufficient depends on the desired layout, the wrapper represents the actual body of the Markdown file. In our case there is a teaser, a sidebar with the TOC of the article, an author box and a link to the latest article. In addition, there are Vue components that you want to use within the Markdown article.

Integrated Vue components

By mixing Markdown and Vue components, you can establish a kind of ContentBuilder. A nice thing about this is that you can easily integrate more complex entities into the article. This makes it easier for the editors.

Here is just a hint. You can embed the components globally as well as locally. If you use the latter, you have to deactivate the Live Edit function. _

The handling of the components does not differ, if you want you can use Nuxt-Content as documentation tool for your Vue components.

Categorize

The category overview is accessed similar to the item details via the CatchAll page. Important in this case are meta information like the color and SEO information.

<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>

This component obtains the content, also via the props.

The start page looks similar, but it does not have a defined category yet.

ArticleCollection

With this component the previews to the articles are provided, optionally can be filtered over the category or the author. It is also possible to collect the most recent articles by date.

<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>

By means of the query language you can define quite precisely which articles you want to read out.

The ArticlePreview component represents the preview of the article at the end, I don't think much needs to be explained.

MagazineCategories

The MagazineCategories component provides the navigation to the existing category pages.

<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>

The "biggest" magic here is that the component collects all Markdown files in the content/articles/ level and explicitly searches for the type=category for safety. Normally there should be no article on this level.

The collected meta information from the Markdown files is then passed into a new array. And from this results the category navigation.

For an editor it is relatively easy to maintain or extend existing categories without changing only one line of Vue code. Everything happens via Markdown and the directory structure there.

Give the author some space

Since the articles are not written by one person alone and we want to show that, we need an author box and the possibility to show an overview of all articles of an author.

The box would be nothing complicated at first, because the author's info could also simply be put into the meta information of the article.

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

This would be quite possible, but would certainly be annoying in the long run.

It would be simpler like this:

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

The nice thing about Nuxt content, you can use it like a NoSQL database. So I placed another subdirectory called authors/ in the content directory.

And there are, yes guessed right, again Markdown files this time with the author's information.

---
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
---

In the article, there is then an author box in the sidebar, which then provides the information. Since the author was noted in the article, it is easy to search for it.

<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>

As you can see, the actual fetch() is quite short, since you are specifically looking for a filename at this point.

The author page itself follows the same pattern as a category page, except that we provide the long form of the author info as an additional block.

<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

As you have seen, you can get quite far with Nuxt content and the given possibilities. Thanks to the query language you can use the Markdown files like a NoSQL DB.