Comment j'ai créé mon site avec Nuxt Content

Comment j'ai créé mon site

J'explique comment j'ai créé mon site avec Nuxt Content

27 avril 2022

Comment j'ai créé mon site avec Nuxt Content

L'ancienne version de mon site commençait à me déranger et il fallait que je le refasse entièrement.
Revoir l'ensemble des pages, la disposition des éléments, les couleurs utilisées.
Aussi, ne plus se contenter d'écrire dans de simples pages HTML statiques.
Mon choix s'est dirigé vers NuxtJS et son module @nuxt/content.

Étant novice avec VueJS, je me suis tout simplement laissé guider par le tutoriel Create a Blog with Nuxt Content.
J'ai volontairement ajouté ou retiré certaines parties pour l'adapter au mieux à mon besoin.

Vue d'ensemble

Voilà les parties qui étaient nécessaires pour que mon projet fonctionne.

  • Une partie Créations (partager mes réalisations)
  • Une partie Blog (écrire des articles)
  • Différentes pages statiques classiques (Accueil, À propos, Contact...)

Voici la structure du projet.

├── assets // Images et feuilles de style
    ├── css
    ├── img
├── components // Éléments réutilisables
├── content // Contenu markdown parsé par NuxtJS
    ├── articles
    ├── creations
├── layouts // Templates pour les pages
├── node_modules
├── nuxt.config.js // Configuration de NuxtJS
├── package.json
├── pages // Différentes pages du site
    ├── blog
        ├── _slug.vue
        ├── index.vue
    ├── creations
        ├── _slug.vue
        ├── index.vue
    ├── about.vue
    ├── contact.vue
    ├── index.vue
    ├── services.vue
├── src
├── static // Images concernant le contenu (choix personnel)
├── tailwind.config.js // Configuration de Tailwind CSS
└── yarn.lock

Les parties Créations et Blog sont sensiblement les mêmes : elles utilisent le même principe et la force de Nuxt Content.

Dans le dossier content, des fichiers markdown (ici mes articles ou mes créations) seront parsés et rendus automatiquement.
Les informations génériques comme le titre, la date de publication, le type, etc. figurent en haut du fichier markdown grâce au Front Matter.

content/articles/my-first-blog-post.md
---
title: My first Blog Post
description: Learning how to use @nuxt/content to create a blog
img: https://cdn.pixabay.com/photo/2022/03/27/18/00/feather-7095765_960_720.jpg
alt: my first blog post
---

# My first blog post

Welcome to my first post using Nuxt Content module !
Tout comme le markdown, des fichiers JSON, YAML, XML ou CSV peuvent être utilisés.

Les pages statiques rangées dans le dossier pages utilisent le rendu VueJS classique.

pages/about.vue
<template>
  <div id="about-container">
    <div class="max-w-4xl mx-auto">
      <div id="about-content" class="w-full flex flex-col items-start justify-center py-48">
        <img class="h-96 rounded-md shadow-lg" :src="require(`~/assets/img/lucas-chaplain-himself.jpg`)" alt="Lucas Chaplain himself">
        <p>
          Je m’appelle Lucas et j’exerce ma passion : <br>
          je réalise des sites et applications web selon vos besoins.
        </p>
      </div>
    </div>
  </div>
</template>

Vous remarquerez que j'ai utilisé Tailwind CSS pour le style global des pages et éléments.

package.json
"devDependencies": {
    "@babel/eslint-parser": "^7.16.3",
    "@nuxt/postcss8": "^1.1.3",
    "@nuxtjs/eslint-config": "^8.0.0",
    "@nuxtjs/eslint-module": "^3.0.2",
    "@nuxtjs/tailwindcss": "^4.2.1",
    "autoprefixer": "^10.4.4",
    "eslint": "^8.4.1",
    "eslint-plugin-nuxt": "^3.1.0",
    "eslint-plugin-vue": "^8.2.0",
    "postcss": "^8.4.12",
    "tailwindcss": "^3.0.23"
  }

Layouts

J'ai choisi d'utiliser un seul layout pour tout le site, mais on peut très bien opter pour des layouts différents.
Par exemple un layout pour les pages statiques, un autre pour la partie blog.

Dans mon cas, le layout default sera utilisé pour toutes les pages.
Ça tombe plutôt bien, car NuxtJS va d'abord chercher un fichier appelé default.vue dans le dossier layouts.
Sans contre-indication de votre part, il sera utilisé partout.

Si vous voulez un layout pour la partie blog (par exemple), créez un fichier blog.vue dans le dossier layouts.
Et suite à cela, vous n'avez qu'à appeler ce dernier dans la page en question (ci-dessous).
pages/blog/_slug.vue
<template>
  <article class="col-span-5">
    <div id="article-data" class="grid grid-cols-4 bg-[#f1f1f1]">
      <img :src="article.img" :alt="article.alt" class="col-span-1">
      <div class="col-span-3 flex flex-col space-y-1.5 justify-center p-8">
        <h1 class="font-jetbrains text-2xl text-gray-700 font-bold">
          {{ article.title }}
        </h1>
        <p class="italic text-indigo-700">
          {{ article.description }}
        </p>
  </article>
</template>

<script>
export default {
  layout: 'blog'
}
</script>

Components

Dans ce dossier, quelques éléments réutilisables.

Recherche d'articles

Permet d'afficher un input qui écoute l'entrée utilisateur.
Si des titres d'articles de blog matchent avec la recherche, ils sont affichés juste en dessous et l'utilisateur peut y accéder rapidement.

components/AppSearchInput.vue
<template>
  <div>
    <input
      v-model="searchQuery"
      type="search"
      autocomplete="off"
      placeholder="Rechercher..."
    >
    <ul v-if="articles.length" class="space-y-1.5">
      <li v-for="article of articles" :key="article.slug">
        <NuxtLink :to="{ name: 'blog-slug', params: { slug: article.slug } }">
          {{ article.title }}
        </NuxtLink>
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  data () {
    return {
      searchQuery: '',
      articles: []
    }
  },
  watch: {
    async searchQuery (searchQuery) {
      if (!searchQuery) {
        this.articles = []
        return
      }
      this.articles = await this.$content('articles')
        .limit(6)
        .search(searchQuery)
        .fetch()
    }
  }
}
</script>

Pour ma part, ce composant est utilisé sur la page pages/blog/index.vue. Simplement appeler le nom du composant.

<AppSearchInput />

Permet d'accéder à l'article suivant ou à l'article précédent lorsque l'utilisateur visite un article de blog.

<template>
  <div class="w-full flex justify-center space-x-12 p-8">
    <NuxtLink
      v-if="prev"
      :to="{ name: 'blog-slug', params: { slug: prev.slug } }"
    >
      <span class="text-blue-500 group-hover:text-blue-700">{{ prev.title }}</span>
    </NuxtLink>
    <span v-else>&nbsp;</span>
    <NuxtLink
      v-if="next"
      :to="{ name: 'blog-slug', params: { slug: next.slug } }"
    >
      <span class="text-blue-500 group-hover:text-blue-700">{{ next.title }}</span>
    </NuxtLink>
    <span v-else>&nbsp;</span>
  </div>
</template>

<script>
export default {
  props: {
    prev: {
      type: Object,
      default: () => null
    },
    next: {
      type: Object,
      default: () => null
    }
  }
}
</script>

Ce composant sera utilisé sur la page pages/blog/_slug.vue.
Les 2 méthodes prev et next sont passées au composant.

<prev-next :prev="prev" :next="next" />

<script>
export default {
  async asyncData ({ $content, params }) {
    const article = await $content('articles', params.slug).fetch()
    const [prev, next] = await $content('articles')
      .only(['title', 'slug'])
      .sortBy('createdAt', 'asc')
      .surround(params.slug)
      .fetch()
    return { article, prev, next }
  }
}
</script>

Alerts

Permet d'afficher, n'importe où dans le site, des éléments : info, warning, success, danger, tip.
Leur contenu est personnalisable grâce au système de slot.
J'ai avant tout créé ceux-ci afin d'être affiché dans mon contenu markdown.

components/global/SuccessBox.vue
<template>
  <div class="box successbox my-12 border-l-8 border-green-500">
    <slot name="successbox">
      default
    </slot>
  </div>
</template>

On peut ensuite utiliser ce composant de cette façon.

<success-box>
  <template #successbox>
    Génial, ça fonctionne !
  </template>
</success-box>

Et obtenir le rendu suivant.

Génial, ça fonctionne !

Avoir un composant logo n'est franchement pas obligatoire, mais permet quand même de gagner du temps et de la place dans les vues.
Ainsi, pour afficher ce dernier, simplement appeler le composant, qui lui-même contient toutes les informations.

Le composant en chair et en os.

components/Logo.vue
<template>
  <img id="nav-logo" src="~/assets/img/logo-lucas-chaplain.svg" width="30" alt="Logo Lucas Chaplain">
</template>

Et son appel dans une vue.

<Logo />

Overlay

J'ai créé ce composant uniquement pour afficher les boutons Infos et Visite lors du survol de mes créations.

<template>
  <div id="overlay">
    <div>
      <NuxtLink
        :to="internalUrl"
        title="Plus d'informations"
      >
        <small>Infos</small>
      </NuxtLink>
      <a
        :href="externalUrl"
        target="_blank"
        title="Visiter"
      >
        <small>Visite</small>
      </a>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    internalUrl: {
      type: String,
      default: null
    },
    externalUrl: {
      type: String,
      default: null
    }
  }
}
</script>

Son utilisation dans la vue est assez simple, il suffit de lui passer les 2 paramètres dont il a besoin.
En l'occurrence, dans ma page pages/creations/index.vue.

<Overlay :internal-url="{ name: 'creations-slug', params: { slug: creation.slug } }" :external-url="creation.url" />

Le paramètre external-url est simplement renseigné dans la Front Matter du fichier markdown.

Attention concernant le nommage.
Dans mon composant Overlay, les noms des deux paramètres sont internalUrl et externalUrl.
Pour les renseigner dans ma vue, je les remplace par internal-url et external-url.

Techno & Lib

Pour mes créations, je souhaitais afficher les technos et librairies que j'ai utilisées pour leur conception.
Je me suis dit qu'un composant serait plus pratique.

En fait, toutes les informations nécessaires sont renseignées sur la fiche de la création en Front Matter.

technos: ["PHP", "JavaScript"]
libs: ["Symfony", "Axios", "AlpineJS"]

Voici le composant Techno.vue. On n'oublie pas d'exporter la props en question.

components/Techno.vue
<template>
  <li>
    <span class="p-1 rounded shadow bg-gray-100">
      <img v-if="techno === 'PHP'" width="25" src="~/assets/img/logos/php.svg" alt="">
      <img v-if="techno === 'JavaScript'" width="25" src="~/assets/img/logos/js.svg" alt="">
      <img v-if="techno === 'Rust'" width="20" src="~/assets/img/logos/rust.svg" alt="">
      <!-- Ajouter autant de logo que l'on souhaite -->
      <small>{{ techno }}</small>
    </span>
  </li>
</template>

<script>
export default {
  props: ['techno']
}
</script>

Ainsi que le composant Lib.vue. Pareil pour la props.

components/Lib.vue
<template>
  <li>
    <span class="p-1 rounded shadow bg-gray-100">
      <img v-if="lib === 'Symfony'" width="20" src="~/assets/img/logos/symfony.svg" alt="">
      <img v-if="lib === 'NuxtJS'" width="20" src="~/assets/img/logos/nuxtjs.svg" alt="">
      <img v-if="lib === 'AlpineJS'" width="20" src="~/assets/img/logos/alpinejs.svg" alt="">
      <!-- Ajouter autant de logo que l'on souhaite -->
      <small>{{ lib }}</small>
    </span>
  </li>
</template>

<script>
export default {
  props: ['lib']
}
</script>

Enfin, je passe les objets technos et libs en paramètre à mon composant.
Ce dernier va simplement afficher leur nom ainsi que leur logo.

<ul id="techno-list">
  <Techno v-for="techno in creation.technos" :key="techno" :techno="techno" />
</ul>
<ul id="lib-list">
  <Lib v-for="lib in creation.libs" :key="lib" :lib="lib" />
</ul>

Pages

J'ai seulement choisi de décrire la page d'accueil ainsi que la page de contact.
Les autres étant moins intéressantes, car plus classiques.

Home

Le dilemme pour la page d'accueil : faire quelque chose de très simple, mais en même temps un petit peu tape-à-l’œil.

J'ai donc fini par opter pour ceci.

  • une disposition CSS grid de 10 par 5.
  • la librairie animejs pour les animations des lettres.

L'animation est chargée uniquement lorsque le DOM est rendu donc dans l'évènement mounted () de NuxtJS.

pages/index.vue
<script>
import anime from 'animejs'
export default {
  mounted () {
    function resetValues (el) {
      anime({
        targets: el,
        scale: [anime.random(0.25, 0.45), 1],
        rotateZ () {
          return 0
        },
        easing: 'linear',
        duration: 3000
      })
    }
    function randomValues (el) {
      anime({
        targets: el,
        opacity: [0, 1],
        scale: [0.3, 1.4],
        rotateZ () {
          return anime.random(0, 360)
        },
        easing: 'easeInOutQuad',
        duration: 150,
        changeComplete: () => {
          resetValues(el)
        }
      })
    }
    const $letters = document.getElementsByClassName('hero-letter')
    Array.prototype.forEach.call($letters, (el) => {
      el.addEventListener('mouseenter', () => {
        randomValues(el)
      })
    })
  }
}
</script>

Contact

Pour la page de contact, j'ai utilisé le service de Formspark.io.
Une soumission du formulaire en AJAX et une notification à l'utilisateur.

Le formulaire est tout à fait standard.

pages/contact.vue
<template>
  <div id="contact-container">
    <div class="h-screen flex flex-col items-center justify-center">
      <form @submit.prevent="submitForm">
        <div class="flex flex-col space-y-1.5">
          <input v-model="email" name="email" type="email" placeholder="Email" autofocus>
        </div>
        <div class="flex flex-col space-y-1.5">
          <input v-model="name" name="name" type="text" placeholder="Nom et prénom" required="required">
        </div>
        <div class="flex flex-col space-y-1.5">
          <textarea v-model="message" name="message" type="text" placeholder="Message" required="required"/>
        </div>
        <button type="submit">
          Envoyer
        </button>
      </form>
    </div>
  </div>
</template>

Et le script qui gère la soumission et la notification.
J'ai utilisé la librairie @nuxtjs/toast pour les notifications. Il faut penser à charger le module dans la configuration globale.

nuxt.config.js
modules: [
  '@nuxtjs/toast'
]
pages/contact.vue
<script>
export default {
  name: 'Contact',
  methods: {
    async submitForm () {
      await fetch(FROMSPARK_URL, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Accept: 'application/json'
        },
        body: JSON.stringify({
          email: this.email,
          name: this.name,
          message: this.message
        })
      })
      this.$toast.success('Parfait, votre message a bien été envoyé !')
    }
  }
}
</script>

Divers

Table des matières

Je n'ai pas parlé de la table des matières que propose Nuxt Content par défaut.
Il est très pratique de chapitrer son contenu.

La mise en place s'organise comme ceci.

pages/blog/_slug.vue
<nav id="article-toc" class="sticky top-0 h-screen p-4 bg-gray-100 border-r-2">
  <ul>
    <li
      v-for="link of article.toc"
      :key="link.id"
      :class="{ 'toc-h2': link.depth === 2, 'toc-h3': link.depth === 3 }"
    >
      <NuxtLink class="font-jetbrains tracking-tighter text-xs" :to="`#${link.id}`">
        {{ link.text }}
      </NuxtLink>
    </li>
  </ul>
  <NuxtLink id="toc-back-to-posts" to="/blog">
    <i class="fa-solid fa-arrow-left" />
    <span>Retour</span>
  </NuxtLink>
</nav>

<script>
export default {
  async asyncData ({ $content, params }) {
    const article = await $content('articles', params.slug).fetch()
    const [prev, next] = await $content('articles')
      .only(['title', 'slug'])
      .sortBy('createdAt', 'asc')
      .surround(params.slug)
      .fetch()
    return { article, prev, next }
  }
}
</script>
Il faut savoir qu'il n'est pas possible d'intégrer les titres H1 dans la table des matières.
Ils sont automatiquement exclus.

En revanche, il est possible de configurer la profondeur maximale à laquelle la table des matières sera construite.
Dans mon cas, je souhaite m'arrêter aux titres H3. Je modifie donc le contenu du fichier nuxt.config.js en fonction.

nuxt.config.js
content: {
  markdown: {
    tocDepth: 3
  }
}

Balise <title> des pages

Par défaut, la balise <title> sera la même pour toutes les pages, car elle est renseignée directement dans le fichier nuxt.config.js.

nuxt.config.js
head: {
  title: 'Lucas Chaplain - Développeur Web Full-Stack'
}

Dans mon cas, je voulais la dynamiser.

Pour les pages statiques, les plus simples donc, il suffit de rajouter ceci.

<script>
export default {
  head () {
    return {
      title: 'Contact - Lucas Chaplain - Développeur Web Full-Stack'
    }
  }
}
</script>

Pour les pages affichant du contenu, en l'occurrence pages/blog/_slug.vue et pages/creations/_slug.vue, il fallait récupérer le titre de l'article ou de la création.

Donc j'ai surchargé la partie asyncData () qui se charge de récupérer le contenu en fonction du slug et de le retourner.
J'ai rajouté une clé title.

pages/blog/_slug.vue
<script>
export default {
  async asyncData ({ $content, params }) {
    const article = await $content('articles', params.slug).fetch()
    const [prev, next] = await $content('articles')
      .only(['title', 'slug'])
      .sortBy('createdAt', 'asc')
      .surround(params.slug)
      .fetch()
    return { article, prev, next, title: article.title }
  }
}
</script>

En accédant à cette clé title, j'ai pu modifier le titre de ma page dynamiquement en fonction du nom de l'article de blog ou bien de la création.

Il suffit d'appeler this.leNomDeMaClé.

<script>
export default {
  head () {
    return {
      title: this.title + ' - Lucas Chaplain - Développeur Web Full-Stack'
    }
  }
}
</script>

Mis à jour le : 27 avril 2022