Laissez moi vous raconter une petite histoire. Celle du déploiement de ce blog. Ce site utilise le framework Hugo qui est connu pour son efficacité pour générer un site statique assez rapidement.

Le site ne possède pas de base de données, le contenu des pages est écrit en markdown et comprends quelques plugins en javascript pour améliorer l’expérience utilisateur. (notamment prism.js pour la coloration syntaxique)

J’avais besoin d’un site web assez simple à maintenir pour publier des articles et Hugo réponds à mes besoins. Mais comment déployer ce site efficacement ?

Git et déploiement continu

Le code source de ce site est dans un dépôt git privé. Au début j’utilisais le service gitlab-page pour mettre en prod le site. C’est fort pratique et ça ne coûte rien. Il m’avait suffit de paramétrer gitlab-ci avec un simple fichier yaml que voici:

image: monachus/hugo

variables:
  GIT_SUBMODULE_STRATEGY: recursive

before_script:
  - git submodule sync --recursive
  - git submodule update --init --recursive

pages:
  script:
  - hugo -v
  artifacts:
    paths:
    - public
  only:
  - master

L’intérêt principale étant de pouvoir déclancher une mise en production lors d’un simple push sur la branche master du dépôt git distant.

Mais malheureusement mettre un nom de domaine en .dev s’est avérer assez fastidieux. C’est pourquoi je me suis tourner vers une autre solution pour le déploiement continu.

La solution Netlify

Netlify permet de gérer des workflows avec Git pour déployer des applications. Je l’utilise en remplacement de gitlab-page pour déployer le site à chaque commit sur master. Cette fois mettre un nom de domaine custom fût beaucoup plus simple que sur gitlab-page.

Une fois le trafic correctement redirigé sur le site web, Netlify génère un certificat SSL avec Let’s Encrypt. (pratique car le .dev n’est accessible qu’en HTTPS 😄)

Une barre de recherche

Après avoir mis en place une pipeline pour déployer en continu avec Git et Netlify, je voulais avoir une barre de recherche. Pour le moment je n’ai pas publié beaucoup de torchons mais à l’avenir, pouvoir chercher un article via une barre de recherche s’avérera fort pratique.

Pour cela j’avais plusieurs solutions. Utiliser ElasticSearch pour Hugo ou Lunr.js. Ou encore, faire appel à une API pour indexer les articles et les rechercher selon une expression régulière. Dans le cas d’ElasticSearch ou Lunr.js, je dois fournir au navigateur un fichier.json pouvant à terme être assez volumineux. Alors qu’en passant par un web service, je n’aurais eu qu’à faire des appels Ajax.

Finalement je n’ai opté pour aucune de ces solutions. J’ai décidé de généré moi même un index au format json pour recenser mes articles. Sachant que je veux que l’expression régulière s’applique uniquement au titre des articles, je génère un json assez simple.

Vous pouvez voir cet index.json en tapant protocod.dev/index.json dans votre navigateur si vous êtes curieux.

Voici ci-dessous le modeste programme qui génère l’index.

package main

import (
	"bufio"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strings"
)

// Crawler struct
type Crawler struct {
	Root      string
	Output    string
	Files     []*CrawledFile
	Separator string
}

// CrawledFile represent crawled file
type CrawledFile struct {
	Title string `json:"title"`
	name  string
	URI   string `json:"uri"`
}

// WalkCallback callback for WalkDir method
type WalkCallback func(c *Crawler, path string, info os.FileInfo, err error) error

func main() {
	crawler := &Crawler{
		Root:      os.Args[1],
		Output:    os.Args[2],
		Separator: "---",
	}

	callback := func(c *Crawler, path string, info os.FileInfo, err error) error {
		file, err := os.Open(path)
		if err != nil {
			return err
		}

		defer file.Close()
		scanner := bufio.NewScanner(file)

		if !info.IsDir() {
			crawled, isDraft := c.Parse(file, scanner)
			if isDraft {
				fmt.Printf("file %s is draft\n", crawled.name)
			} else {
				crawler.Files = append(c.Files, crawled)
				fmt.Printf("file %s indexed\n", crawled.name)
			}
		}
		return nil
	}

	err := crawler.WalkDir(callback)

	if err != nil {
		panic(err)
	}

	err = crawler.CreateIndex()

	if err != nil {
		panic(err)
	}
}

// CreateIndex create index for search bar
func (c *Crawler) CreateIndex() error {
	json, err := json.Marshal(c.Files)

	if err != nil {
		return err
	}

	indexFile, err := os.Create(c.Output)
	defer indexFile.Close()

	if err != nil {
		return err
	}

	indexFile.WriteString(string(json))
	return nil
}

// WalkDir get all files
func (c *Crawler) WalkDir(callback WalkCallback) error {
	err := filepath.Walk(c.Root, func(path string, info os.FileInfo, err error) error {
		return callback(c, path, info, err)
	})

	return err
}

// Parse Parse current file
func (c *Crawler) Parse(file *os.File, scanner *bufio.Scanner) (*CrawledFile, bool) {
	name := file.Name()
	uri, err := c.GetFileName(name)

	if err != nil {
		panic(err)
	}

	crawled := &CrawledFile{
		name: name,
		URI:  uri,
	}

	inHeader := false

	for scanner.Scan() {
		line := scanner.Text()

		if !inHeader {
			inHeader = strings.Contains(line, c.Separator)
		} else {
			if strings.Contains(line, "title:") {
				replacer := strings.NewReplacer("\"", "", "\"", "", "title:", "")
				crawled.Title = strings.Trim(replacer.Replace(line), " ")
			} else if strings.Contains(line, "draft:") && strings.Contains(line, "true") {
				return crawled, true
			} else if strings.Contains(line, c.Separator) {
				break
			}
		}
	}

	if err := scanner.Err(); err != nil {
		panic(err)
	}

	return crawled, false
}

// GetFileName extract filename
func (c *Crawler) GetFileName(path string) (string, error) {
	if path[len(path)-3:len(path)] == ".md" {
		name := path[len(c.Root) : len(path)-3]
		if strings.Contains(name, ".") {
			name = name[:len(name)-3]
		}
		return name, nil
	}
	return "", errors.New("Not a markdown")
}

Ensuite, j’ai intégré un framwork css que j’aime beaucoup, à savoir Semantic UI et plus particulièrement le plugin pour implémenter une barre de recherche.

Ci dessous le code html et javascript pour la barre de recherche

search.html

<div class="ui search">
    <div class="ui icon input">
      <input id="search" class="prompt" type="text" placeholder="Recherche...">
      <i class="search icon"></i>
    </div>
    <div class="results"></div>
</div>

extended_footer.html

<script type="text/javascript">
$(document).ready(function() {
    $.getJSON({{ "index.json" | absURL }})
        .done(function(index) {
            const results = index.map(data => {
                return { title: data.title, uri: data.uri }
            });
            $("#search").keyup(function() {
                $('.ui.search').search({
                    source: results,
                    onSelect(result, response) {
                        window.location.href = result.uri;  
                    }  
                });
            });
        });
});
</script>

Makefile à la rescousse !

Le résultat me paraît assez satisfaisant. Cependant n’oublions pas que je dois générer un nouvel index pour la barre de recherche à chaque déploiement. J’ai donc créé le fichier Makefile suivant:

.PHONY: index build all
all: index build
index:
	go run generate_index.go content static/index.json
build:
	hugo

Ensuite j’ai précisé à Netlify d’exécuter la commande make all pour généré l’index et le site web avec hugo. Et le tour est joué ! 😄

Merci d’avoir lu ce court article, j’espère pouvoir rédiger quelque chose d’un peu plus consistant pour le prochain torchon !