Utiliser AngularJS en respectant les recommandations W3C et SEO

AngularJS est un régale pour toute création d'Application Web de petite et moyenne taille. J'entends par là, des ensembles d'interfaces web ou l'utilisateur est sollicité à participer au remplissage de contenu par l'intermédiaire de champs, listes, boutons, etc.

AngularJS

Le principe est le suivant : sortir le contenu utile du HTML pour le ranger dans un scope JavaScript afin de le manipuler plus aisément et parsemer le HTML de référence à ce scope de contenu. L'avantage est que le contenu a sa propre vie, et n'est plus figé dans le HTML et surtout toute mise à jour de contenu dans le JavaScript entraîne sa mise à jour dans le HTML, bien pratique.

Le revers de la médaille est que la source HTML mangée par les moteurs de recherche pour le référencement ne ressemble plus qu'à un ensemble de variables, et les balises et attributs HTML rencontrés font pâlir n'importe quel validateur W3C.

C'est parti pour un tutoriel vous expliquant comment contenter le W3C pour pouvoir correctement implémenter vos recommandations SEO avec AngularJS.

AngularJS pour du contenu interactif

Construire son Site Web intégralement avec AngularJS, alors que son but est de présenter du contenu au visiteur et aux moteurs de recherche est le meilleur moyen de faire un magnifique site introuvable.

Cependant, parsemer un Site Web de divers mécanismes apportés par la librairie AngularJS sur les formulaires ou le contenu extensible est un jeu d'enfant et n'entrave en rien un bon référencement et de bonnes pratiques HTML, à condition de respecter les guidelines que va vous fournir cet article.

Les ng-* deviennent des data-ng-*

Partons d'un pattern extrêmement simple :

HTML

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Angular pour le W3C et le SEO</title></script>
</head>
<body>
    <!-- AngularJS, applique toi ! -->
    <div ng-app="">
        <p>
            Écrivez dans ce champ :<br>
            <!-- Choisi ton contenu dans `content` -->
            <input type="text" ng-model="content"><br>
            Résultat : {{ content }}
        </p>
    </div>
    <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.5/angular.min.js">
</body>
</html>

Démo

Écrivez dans ce champ :

Résultat : {{ content }}

Non Ce qui ne va pas

La voix du W3C ne se fait pas prier et vous recevrez les remontrances suivantes :

  • Error: Attribute ng-app not allowed on element div at this point.
  • Error: Attribute ng-model not allowed on element input at this point.

car effectivement il n'est pas possible d'inventer ses propres attributs en HTML5 à moins de les préfixer par data-.

Oui Ce qu'il faut faire

Pour régler ce point, préfixez tous les attributs de AngularJS avec data- ce qui nous donne :

<div data-ng-app="">
    <p>
        Écrivez dans ce champ :<br>
        <input type="text" data-ng-model="content"><br>
        Résultat : {{ content }}
    </p>
</div>

Utilisez data-ng-bind plutôt que {{ }}

Imaginons ce texte qui est massacré pour les moteurs de recherche.

<!-- ... -->
<!-- Appelons par défaut notre personnage John Doe -->
<div data-ng-app="" data-init="name='John Doe'">
   <h1>L'histoire dont « qui vous voulez » est le zéro</h1>
   <p>
       Quel nom pour l'histoire ?<br>
       <!-- Renommez John ! -->
       <input type="text" data-ng-model="name">
   </p>
   <!-- Pas de nom, pas d'histoire ! -->
   <p data-ng-show="name">
       Voici l'histoire de {{ name }}. {{ name }} était amoureux d'une princesse, 
       mais n'était pas prince. 
       {{ name }} mourru très vieux sans jamais avoir épousé sa princesse 
       —{{ name }} n'ayant effectivement pas réussi à se faire adopter par un noble—.   
   </p>
</div>
<!-- ... -->

Démo

L'histoire dont « qui vous voulez » est le zéro

Quel nom pour l'histoire ?

Voici l'histoire de John Doe. John Doe était amoureux d'une princesse, mais n'était pas prince. John Doe mourru très vieux sans jamais avoir épousé sa princesse —John Doe n'ayant effectivement pas réussi à se faire adopter par un noble—.

Non Ce qui ne va pas

Ici le soucis est pour les moteurs de recherche. S'ils souhaitent indexer votre histoire, ils n'entendront jamais parler du brave John Doe mais d'un certain {{ name }}.

Oui Ce qu'il faut faire

Pour solutionner ce problème il faut tout simplement remplacer {{ name }} par <span data-ng-bind="name">John Doe</span> ainsi les moteurs de recherche pourront inscrire « la gloire » de John Doe dans l'histoire.

<!-- ... -->
<div data-ng-app="" data-init="name='John Doe'">
   <p>L'histoire dont « qui vous voulez » est le zéro</p>
   <p>
       Quel nom pour l'histoire ?<br>
       <input type="text" data-ng-model="name">
   </p>
   <p data-ng-show="name">
       Voici l'histoire de <span data-ng-bind="name">John Doe</span>. <span data-ng-bind="name">John Doe</span> était amoureux d'une princesse, 
       mais n'était pas prince. 
       <span data-ng-bind="name">John Doe</span> mourru très vieux sans jamais avoir épousé sa princesse 
       —<span data-ng-bind="name">John Doe</span> n'ayant effectivement pas réussi à se faire adopter par un roi—.   
   </p>
</div>
<!-- ... -->

Si votre application permettait à quelqu'un de changer le nom et de valider cela côté serveur dans une base de donnée, il verrait son changement en temps réel, mais les moteurs de recherche en affichant la page à partir de cet instant pourrait indexer le nouveau nom pour l'histoire.

Des Directives par attributs au lieu de balises

Il est possible grâce à AngularJS de créer ses propres directives comme dans cet exemple :

HTML

<div data-ng-app="new-directive">
    <unknown-markup-directive></unknown-markup-directive>
</div>

JS

angular.module("new-directive", []).directive("unknownMarkupDirective", function() {
    return {
        template : "<p>Ceci a été créé par une Directive !</p>"
    };
});

Démo

Non Ce qui ne va pas

Le problème ici c'est que le code suivant <unknown-markup-directive></unknown-markup-directive> ne passe pas la validation car il n'est pas permis en HTML d'inventer ses propres balises. Cependant, l'utilisation de directives étant fort pratique il est intéressant de savoir qu'il est également possible de les utiliser en tant que nom d'attribut ou nom de classe.

Oui Ce qu'il faut faire

Aussi pour résoudre notre problème précédent, il suffit de faire comme suit :

HTML

<div data-ng-app="new-directive">
    <div data-new-directive></div>
</div>

JS

angular.module("new-directive", []).directive("newDirective", function() {
    return {
        /* On active l'utilisation de la directive avec un attribut ! */
        restrict : "A",
        template : "<p>Ceci a été créé par une Directive !</p>"
    };
});

Initialiser le Scope depuis une source indexable

Quand vous mettez en marche une application AngularJS avec un fichier JavaScript pour initialiser le $scope ou à travers data-ng-init, les informations sont bien attachées au couple View-Model (HTML-JS) mais rien n'est indexable par les moteurs de recherche. Voyez plutôt.

HTML

<div data-ng-app="pokemon" data-ng-controller="popular">
    <p>Pokemons Populaires</p>
    <ul>
        <li data-ng-repeat="pokemon in popularPokemons">
            {{ pokemon.name }} est populaire.
            <span data-ng-if="pokemon.cover">
                <strong>Il est sur une pochette de jeu.</strong>
            </span>
            <span data-ng-if="!pokemon.cover">
                Il n'est pas sur une pochette de jeu.
            </span> -
            <a href="javascript:;" data-ng-click="remove($index)">Supprimer</a>
        </li>
    </ul>
    <form data-ng-init="addPokemon={ cover: 'false' }">
        <strong>Vous en connaissez un autre ?</strong><br>
        Nom : <input type="text" data-ng-model="addPokemon.name"><br>
        <select data-ng-model="addPokemon.cover">
            <option value="false">Il n'est pas sur une pochette de jeu.</option>
            <option value="true">Il est sur une pochette de jeu.</option>
        </select>
        <button data-ng-click="add()" data-ng-disabled="!addPokemon.name">Ajouter</button>
    </form>
</div>

JS

angular.module("pokemon", []).controller("popular", function($scope) {
    $scope.popularPokemons = [
        { name: "Pikachu", cover: true }, 
        { name: "Bulbizarre", cover: false }, 
        { name: "Carapuce", cover: false }, 
        { name: "Dracaufeu", cover: true }, 
        { name: "Mewtwo", cover: false }, 
    ];
    $scope.add = function () {
        $scope.addPokemon.cover = ($scope.addPokemon.cover === "true");
        $scope.popularPokemons.push($scope.addPokemon);
        $scope.addPokemon = { cover: "false" };
    }
    $scope.remove = function (pos) {
        $scope.popularPokemons.splice(pos, 1);
    }
});

Démo

Pokemons Populaires


Vous en connaissez un autre ?
Nom :

Non Ce qui ne va pas

Le problème ici, c'est que pour un indexeur de contenu, ce morceau de code n'a rien de très appétissant.

<ul>
    <li data-ng-repeat="pokemon in popularPokemons">
        {{ pokemon.name }} est populaire.
        <span data-ng-if="pokemon.cover">
            <strong>Il est sur une pochette de jeu.</strong>
        </span>
        <span data-ng-if="!pokemon.cover">
            Il n'est pas sur une pochette de jeu.
        </span> -
        <a href="javascript:;" data-ng-click="remove($index)">Supprimer</a>
    </li>
</ul>
  • Nous allons donc le cacher aux yeux de l'indexeur en expliquant que c'est un <template> avec la balise HTML5 associée.
  • Nous allons également donner à manger le vrai contenu dans le code HTML source que nous allons cacher à l'utilisateur.
  • Nous allons alimenter le $scope à partir du HTML comme référence et non plus le piéger dans le JavaScript.

Oui Ce qu'il faut faire

Modifions alors le code HTML qui va arriver du serveur et qui sera parfaitement indexable pour ce qui est du contenu et parfaitement ignoré pour ce qui est du template (la balise <template>).

HTML

<!-- ... Début identique à la précédente version ... -->
<ul class="popular-pokemon-list">
    <li data-ng-if="false">
        <span class="pokemon">Pikachu</span> est populaire. 
        <span class="cover">
            <strong>Il est sur une pochette de jeu.</strong>
        </span>
    </li>
    <li data-ng-if="false">
        <span class="pokemon">Bulbizarre</span> est populaire.
        Il n'est pas sur une pochette de jeu.
    </li>
    <li data-ng-if="false">
        <span class="pokemon">Carapuce</span> est populaire.
        Il n'est pas sur une pochette de jeu.
    </li>
    <li data-ng-if="false">
        <span class="pokemon">Dracaufeu</span> est populaire. 
        <span class="cover">
            <strong>Il est sur une pochette de jeu.</strong>
        </span>
    </li>
    <li data-ng-if="false">
        <span class="pokemon">Mewtwo</span> est populaire.
        Il n'est pas sur une pochette de jeu.
    </li>
<template class="popular-pokemon-template">
    <li data-ng-repeat="pokemon in popularPokemons">
        {{ pokemon.name }} est populaire.
        <span data-ng-if="pokemon.cover">
            <strong>Il est sur une pochette de jeu.</strong>
        </span>
        <span data-ng-if="!pokemon.cover">
            Il n'est pas sur une pochette de jeu.
        </span> -
        <a href="javascript:;" data-ng-click="remove($index)">Supprimer</a>
    </li>
</template>
</ul>
<!-- ... Fin identique à la précédente version ... -->

Dans notre exemple précédent, tous les data-ng-if vont être retiré du DOM et la version contenu dans <template> va être exécuté par AngularJS après avoir été activé à l'aide du code suivant :

JS

/* Récupération du Template */
var template = document.getElementsByClassName("popular-pokemon-template")[0],
    /* Activation du Template par copie */
    content = document.importNode(template.content, true),
    /* Zone d'atterrissage du conteru de Template */
    list = document.getElementsByClassName("popular-pokemon-list")[0],
    pokemons = list.getElementsByTagName("li"),
    popularPokemons = [];
/* Alimentation du futur `Scope` Angular */
Array.prototype.forEach.call(pokemons, function (pokemon) {
    popularPokemons.push({ 
        name: pokemon.getElementsByClassName("pokemon")[0].textContent, 
        cover: (pokemon.getElementsByClassName("cover").length > 0) ? true : false
    });
});
/* Dépôt du Template dans le DOM */
list.appendChild(content);

La variable popularPokemons peut ensuite être associée au Scope initial de AngularJS.

/* Module AngularJS */
angular.module("pokemon", []).controller("popular", function($scope) {
    $scope.popularPokemons = popularPokemons;
    /* ... Reste identique à la précédente version ... */
});

Note :

Libre à vous d'utiliser la techno côté serveur qui vous délivrera le HTML près à être indexé par les moteurs. Voici un exemple en Node.js avec le module NodeAtlas (Utilisant le moteur de template EJS 2).

<!-- ... -->
<ul class="popular-pokemon-list">
    <% for (var pokemon in pokemons) { %>
    <li data-ng-if="false">
        <span class="pokemon"><%- pokemon.name %></span> est populaire.
        <% if (pokemon.cover) { %> 
        <span class="cover">
            <strong>Il est sur une pochette de jeu.</strong>
        </span>
        <% } else { %>
        Il n'est pas sur une pochette de jeu.
        <% } %> 
    </li>
    <% } %> 
<template class="popular-pokemon-template">
    <li data-ng-repeat="pokemon in popularPokemons">
        {{ pokemon.name }} est populaire.
        <span data-ng-if="pokemon.cover">
            <strong>Il est sur une pochette de jeu.</strong>
        </span>
        <span data-ng-if="!pokemon.cover">
            Il n'est pas sur une pochette de jeu.
        </span> -
        <a href="javascript:;" data-ng-click="remove($index)">Supprimer</a>
    </li>
</template>
</ul>
<!-- ... -->

Délivrer du contenu derrière chaque URL

Si vous utilisez AngularJS pour quelque chose de plus complet que la vérification de formulaire, il est possible que votre site n'est qu'une seule et unique url d'entrée actuellement.

Ainsi à la page http://www.mon-site.com/ vous faites tourner le code suivant :

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Angular pour le W3C et le SEO</title>
</head>
<body>
    <!-- AngularJS, applique toi ! -->
    <div data-ng-app="app" data-ng-controller="main">
        <div data-main-module></div>
    </div>
    <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.5/angular.min.js"></script>
</body>
</html>

JS

angular.module("app", []).controller("main", function($scope, $location, $sce) {
    // Liste de vos différentes pages.
    var content = {
        "/": "<div><!-- Contenu de Home --></div>",
        "/products/": "<div><!-- Contenu de Products --></div>",
        "/contact/": "<div><!-- Contenu de Contacts --></div>",
    };
    // Afficher la bonne page en fonction du changement d'url.
    $scope.$on("$locationChangeStart", function (event, next) {
        var hash = /\/#(\/[a-z]*\/?)/g.exec(next);
        $scope.content = $sce.trustAsHtml(content[hash ? hash[1] : "/"]);
    });
    // Changer artificiellement d'url.
    $scope.goTo = function (url) {
        $location.path(url).replace();
    }
}).directive("mainModule", function() {
        return {
            restrict : "A",
            template : "<div data-ng-bind-html='content'></div>" + 
                       "<button data-ng-click=\"goTo('/')\">Accueil</button>" + 
                       "<button data-ng-click=\"goTo('/products/')\">Produits</button>" + 
                       "<button data-ng-click=\"goTo('/contact/')\">Contactez-nous</button>"
    };
});

et en cliquant sur chaque lien vous allez afficher les urls :

avec pour chacune un contenu différent

Démo

Non Ce qui ne va pas

Vous constaterez en changeant de page via les boutons que vos urls contiennent toutes des #. Cela signifie que vous n'avez pas changé de page car tout ce qui suit ce caractère n'est pas interprété par les moteurs de recherche. L'intégralité de votre site aux yeux de Google se résume a une unique page HTML... vide...

Nous avons également ajouté nous même du code JavaScript pour lier le changement d'url à l'état de la page, c-à-d que si vous changez à la main l'url de votre bar d'adresse pour une autre page existante celle-ci va s'afficher ce qui vous permet au moins de partager des urls avec # avec vos amis et qu'ils puissent arriver tout de même sur la bonne page.

Il y a deux choses à faire pour rendre cela SEO friendly.

  • Il ne faut plus que les adresses contiennent de #, mais que ce soit de vrais adresses consultables si je me rends dessus.
  • Il faut qu'en me rendant à chaque adresse directement, il y ai un code source initial en « response » à la « request » serveur qui puisse être indexé par des moteurs de recherche.

Oui Ce qu'il faut faire

L'unique page précédente devient alors trois pages distinctes :

avec sur chaque page, au retour « response » du serveur, le contenu dédié à la page.

/

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Accueil</title>
</head>
<body>
    <!-- AngularJS, applique toi ! -->
    <div data-ng-app="app" data-ng-controller="main">
        <div data-ng-if="false"><!-- Contenu de Home --></div>
        <div data-main-module></div>
    </div>
    <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.5/angular.min.js"></script>
    <script src="app.js"></script>
</body>
</html>

/products/

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Notre Wallpaper</title>
</head>
<body>
    <!-- AngularJS, applique toi ! -->
    <div data-ng-app="app" data-ng-controller="main">
        <div data-ng-if="false"><!-- Contenu de Products --></div>
        <div data-main-module></div>
    </div>
    <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.5/angular.min.js"></script>
    <script src="app.js"></script>
</body>
</html>

/contact/

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Contactez-nous</title>
</head>
<body>
    <!-- AngularJS, applique toi ! -->
    <div data-ng-app="app" data-ng-controller="main">
        <div data-ng-if="false"><!-- Contenu de Contact --></div>
        <div data-main-module></div>
    </div>
    <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.5/angular.min.js"></script>
    <script src="app.js"></script>
</body>
</html>

C'est ensuite que le code JavaScript va être parsé et que le pushState de AngularJS va être activé pour permettre aux utilisateur de changer l'adresse dynamiquement (sans rechargement de page, c-à-d sans request/response) et de charger les contenus.

JS

app.js

angular.module("app", []).config(function($locationProvider) {
    $locationProvider.html5Mode(true);
}).controller("main", function($scope, $location, $sce) {
    // Liste de vos différentes pages.
    var content = {
        "/": "<div><!-- Contenu de Home --></div>",
        "/products/": "<div><!-- Contenu de Products --></div>",
        "/contact/": "<div><!-- Contenu de Contacts --></div>",
    };
    // Changer d'url.
    $scope.goTo = function (url) {
        $location.path(url).replace();
        $scope.content = $sce.trustAsHtml(content[url]);
    }
    // Charger la bonne page selon l'url
    $scope.content = $sce.trustAsHtml(content[$location.url()]);
}).directive("mainModule", function() {
        return {
            restrict : "A",
            template : "<div data-ng-bind-html='content'></div>" + 
                       "<button data-ng-click=\"goTo('/')\">Accueil</button>" + 
                       "<button data-ng-click=\"goTo('/products/')\">Produits</button>" + 
                       "<button data-ng-click=\"goTo('/contact/')\">Contactez-nous</button>"
    };
});

Comment ça marche ?

Quand un moteur de recherche affiche l'une des trois pages, celle-ci est indexé via sont url avec le contenu déjà présent dessus dans la source de la « response ».

Quand un utilisateur affiche l'une des trois pages, son contenu initial est caché et app.js fait tourner l'application en chargeant le bon contenu en fonction de l'url. Après quoi, chaque fois que l'utilisateur changera de page, sa page ne se rechargera pas (le code source initial sera toujours celui de la page d'arrivée) mais sa barre d'adresse changera bien et AngularJS mettra le contenu à jour. S'il actualise la page depuis une autre url après navigation à travers plusieurs page, c'est la nouvelle page que sera chargé depuis la « response » et le cycle recommencera.

Ce mécanisme est bien entendu possible avec le routage officiel d'AngularJS avec $routeProvider et vous êtes libre d'utiliser la technologie Back-end de votre choix pour afficher le bon contenu source derrière chaque url de votre site en retour de la « response ».

Exemple Live

Sur mon site de présentation réalisé avec NodeAtlas, en arrivant par l'une de ses urls :

le code source de la « response » sera uniquement celui de l'onglet ouvert. Tous le contenu des autres onglets sera chargé ultérieurement via des requêtes asynchrones. En changeant d'onglet, l'adresse changera mais pas le code source (la page ne sera pas rechargée, il n'y aura plus de « request / response »). Si vous rechargez la page depuis une autre url que celle d'arrivée, alors une « request / response » sera effectué avec seulement le contenu de cette page, et le reste viendra par contenu asynchrone, etc.

Vos astuces W3C et SEO ?

Il doit exister d'autres situations ou de bonnes pratiques permettraient à du code AngularJS d'être parfaitement référencé aussi j'alimenterai cet article avec d'autres exemples dans le futur.

Et vous ? Des méthodes à partager ?