From Dotclear to Eleventy 4
Table of content
- Introduction
- Install eleventy
- Migrating the content
- Rebuilding navigation and pages
- Other features
The task
We have the content copied over to the Huwindity starter. It now needs to look and behave the way it did with dotclear. Each article should display with its comments, with links to the next post and users should be able to list articles by tags and categories.
Display comments with articles
Comments are saved in a markdown file in a folder with the name of the article. Comments are loaded from the base layout (including commentaires.njk) where the comment path is compared with the url or the page. When it matches, the comment is added to the list of entries.
{% set entrees = [] %}
{%- for comment in collections.comment -%}
{% set length = comment.url.split("/").length %}
{% set slug = "/"+comment.url.split("/")[length-3]+"/" %}
{% if slug === page.url %}
{% set entrees = (entrees.push(comment), entrees) %}
{% endif %}
{%- endfor -%}
The layout then loops on the list of entries to display every related comments with all meta data.
{%- for entree in entrees -%}
<li class="list-none !ml-0 pb-9">
{% if entree.data.email %}{% set email = entree.data.email %}{% else %}{% set email = "anonymous@example" %}{% endif %}
<img
src="{% gravatar email, 48, "retro" %}"
title="{{ entree.data.email }}"
alt="{{ entree.data.author }} Avatar"
width="48"
height="48"
class="float-left mr-2"
/> par
{% if entree.data.site %}
<a href="{{ entree.data.site }}">{{ entree.data.author }}</a><br>
{% else %}
{{ entree.data.author }}<br>
{% endif %}
<em class="text-text-light">le {{ entree.date | date }}</em><br>
{{ entree.content | md | safe }}
</li>
{%- endfor -%}
This includes the gravatar of the comment poster from the Gravatar website. This site returns the image users posted for their own email address or a fancy unique avatar that helps follow conversation in comments.
Thanks to an eleventy shortcode, gravatar generates automatically the url of the image from the user email, as requested by the gravatar website.
// Expose the shortcode for gravatar
eleventyConfig.addShortcode('gravatar', gravatarShortcode);
const gravatarShortcode = (email, size = 72, defaultImage = 'mp') => {
// Clean up the email address
// - Remove any leading or trailing spaces
// - Make it lowercase
const cleanEmail = email.trim().toLowerCase();
// Create an MD5 hash from the cleaned email address
const emailHash = crypto
.createHash('md5')
.update(cleanEmail)
.digest('hex');
// Return a URL image with the hash appended
return `https://www.gravatar.com/avatar/${emailHash}?s=${size}&d=${defaultImage}`;
};
Handle footnotes
I already wrote I used and abused footnotes because they were easy to write in Dotclear. I had to make sure thay could be handled aswell under eleventy. For this, I used a plugin to mardown-it, used by Huwindty to parse mardown into HTML. It’s simply called markdown-it-footnote. Once installed, I just need to add it to markdown-it options use(mditFtNote)
(see the commit) and respect the syntax [^n]
on every posts where I want a foot note:
La tâche a été confiée au **cloud computing Azure de Microsoft**, entreprise état-unienne donc[^1]. Bon, il y a eu des protestations (soupçon de favoritisme, une atteinte à la souveraineté numérique, protection des données personnelles…) mais le gouvernement a mission de moderniser la France alors le chantier a été lancé sans attendre.
...
[^1]: Sans même passer par un appel d'offre cela dit en passant mais c'est une autre histoire.
Off course, I also made sure that my conversion script correctly changed the Dotclear syntax into the Markdown one.
Links to next and previous articles
The old blog allowed to browse to the next and previous articles so I wanted to add this behaviour on the layout.
Luckilly eleventy comes with this feature.
{% set previousPost = collections.posts | getPreviousCollectionItem(page) %}
{% set nextPost = collections.posts | getNextCollectionItem(page) %}
The only trick for me is because the collection order is inverted my next is previous and vice versa.
{% if nextPost %}
<div class="mt-6 h-15 block flex-left max-w-19/40 truncate">
<div class="float-left mt-2 text-text-light">
⮜ <span class="sr-only">billet précédent</span></div>
<div class="border-text-light [&&]:text-text-light border-1 rounded-2xl p-1 px-2 mt-1">
<a href="{{ nextPost.url }}" class="block truncate">{{ nextPost.data.title }}</a>
</div>
</div>
{% endif %}
{% if previousPost %}
<div class="{% if nextPost %}-mt-15{% else %}mt-2{% endif %} h-15 block flex-right max-w-19/40 ml-auto text-right">
<div class="float-right mt-2 text-text-light">
<span class="sr-only">billet suivant</span> ⮞ </div>
<div class="border-text-light [&&]:text-text-light border-1 rounded-2xl p-1 px-2 mt-1">
<a href="{{ previousPost.url }}" class="block truncate">{{ previousPost.data.title }}</a>
</div>
</div>
{% endif %}
Counting comments
When listing articles, it is nice to know in advance how many comments there may be under the article. For this I do the same as in the article page, I take the collection of comments (all comments are taged comment
which makes it easy), I only match comments that have the same url as the article slug and count them.
<div class="border-text-light [&&]:text-text-light border-1 rounded-2xl p-1 px-2">
<em class="text-primary"> ✍</em>
{% set entrees = [] %}
{%- for comment in collections.comment -%}
{% set length = comment.filePathStem.split("/").length %}
{% set slug = "/"+comment.filePathStem.split("/")[length-2]+"/" %}
{% if slug === post.data.page.url %}
{% set entrees = (entrees.push(comment), entrees) %}
{% endif %}
{%- endfor -%}
{% if entrees.length %}
{{ entrees.length }} commentaire{% if entrees.length > 1 %}s{% endif %}
{% else %}
pas de commentaire
{% endif %}
</div>
Tags and Categories
Each post had one category and could have several tags. These were listed under each title. They are easy to get since my script copied them over into the front matter. Getting them into a nunjunk template is just as simple as using {{ }}
.
{% if categorie and date %}
<em class="text-text-light">in <a href="/{{ categorie }}/" class="text-text-light">{{ categorie }}</a> le {{ date | date }}</em><br/>
{% endif %}
{% if tags %}
<div class="flex-row">
{% for tag in tags %}
<em class="text-primary">🏷</em> <em class="text-text-light"><a href="/tag/{{ tag | slugify }}/">{{ tag }}</a></em>
{% endfor %}
</div>
{% endif %}
That done, links need to lead to new tag and category pages.
Category pages
I don’t have many categories, they are defined as data in categories.json
that I created manually.
[
{
"name": "toering",
"title": "Toering, visitez les Pays-Bas"
},
{
"name": "nederlandjes",
"title": "Nederlandjes, Uniquement chez les dutch"
},
{
"name": "ik-ben-frans",
"title": "Ik ben frans, Être Français chez les Bataves"
},
{
"name": "dagelijks",
"title": "Dagelijks, mon quotidien à Amsterdam"
}
]
Then, I need to create for each category, 1. a collection of posts and 2. a template with a front matter to generate as many pages as needed. This would look as bellow:
eleventyConfig.addCollection('toering', function (collection) {
return collection.getFilteredByGlob("./src/pages/posts/**/*.md")
.filter((item) => !item.data.tags.includes("comment"))
.filter((item) => item.data.categorie === 'toering');
});
---
layout: liste
title: Toering, visitez les Pays-Bas
categorie: toering
pagination:
data: collections.toering
size: 12
alias: revue
---
Both collections and templates can be defined in the eleventy config (eleventy.js
). So by looping on the category json, it is possible to produce in one go all the collections and all the templates for all categories.
// categories
const categories = require('./src/_data/categories.json');
categories.map((cat) => {
// collection for each category
eleventyConfig.addCollection(cat.name, function (collection) {
return collection.getFilteredByGlob("./src/pages/posts/**/*.md")
.filter((item) => !item.data.tags.includes("comment"))
.filter((item) => item.data.categorie === cat.name);
});
// template for each category
eleventyConfig.addTemplate(cat.name+".md", `Les articles classés dans ${cat.name}`, {
layout: "liste.njk",
categorie: cat.name,
title: cat.title,
pagination: {
data: `collections['${cat.name}']`,
size: PAGE_SIZE,
alias: 'revue',
},
});
});
The category pages use the layout liste
. Read more about this layout bellow.
Under the pagination:
attribute we can see that the data
will be the collection for the ${cat.name} category and that data will be send as an array called revue
to the layout.
The size: PAGE_SIZE
tells the layout to only list 12 items on the page. (PAGE_SIZE is defined on top of the config file). Eleventy will handle the pagination (as documented here) and the links to the next pages if there are more than 12 items.
Tag pages
In eleventy, tags generate automatically their collections so there is no need to create a collection for each tag in eleventy.js
. This is convinient because there are many many tags in my blog.
Helas, when you use the default collections
, the pagination already iterates through the tags (see example here) so it cannot create a second iteration with multiple pages.
---
layout: base
eleventyExcludeFromCollections: true
eleventyComputed:
title: 'Mot clé : {{ tag }}'
pagination:
data: collections
size: 1
alias: tag
title: Mot clé {{ tag }}
permalink: /tag/{{ tag }}/
---
The above code Will generate a list of posts for each tag, but won’t be capable to display these lists on multiple pages with a nice pagination. For this, we’ll need another solution.
There are many workaroud shared here and there. I decided to follow @chriskirknielsen with his Double-Pagination in Eleventy. Basically, this consists in building a flat array of all tags, already paginated based on the amount of post sharing this tag. The array would be something like:
[ {tag1-page1 }, {tag1-page2}, {tag2-page1}, {tag3-page1}, {tag3-page2}, {tag3-page3}, {tag3-page4}… ]
The piece of code that generate such collection can be summarised as follow:
- Create an array with all tags
- iterate on each tag todo the following:
- get the collection of all posts with this tag
- slice this collection by chunks of
PAGE_SIZE
posts - add the url of the page that will list these posts
- add pagination utils with page count and links to next and previous pages
// tags
// inspired by https://chriskirknielsen.com/blog/double-pagination-in-eleventy/
// this is only a collection, the template is tag.md
eleventyConfig.addCollection('tags', (collection) => {
console.log("in tags collec")
// Retrieve a list of all posts
const allTags = Array.from(new Set( // ← deduplicate
collection.getFilteredByGlob("./src/pages/posts/**/*.md") // ← only posts
.filter((item) => !item.data.tags.includes("comment")) // ← exclude comments
.map((item) => item.data.tags) // ← keep only tags
.flat() // ← flaten array
));
const allPostsPerTag = allTags
.map((t) => {
// Grab every post of the current tag `t`, sorted by post date in descending order
const allPostsOfTag = collection.getFilteredByTag(t).sort((a, b) => new Date(b.date) - new Date(a.date));
// split array of posts into array of chuncks with PAGE_SIZE posts
const chunkedPostsOfTag = [];
for (let i = 0; i < allPostsOfTag.length; i += PAGE_SIZE) {
chunkedPostsOfTag.push(allPostsOfTag.slice(i, i + PAGE_SIZE))
}
// permalink in the form /tag/tagName/page/
const getPageHref = (index) => `/tag/${eleventyConfig.getFilter('slugify')(t)}/${index > 0 ? `${index}/` : ``}`;
const maxPageOfTag = chunkedPostsOfTag.length;
const chunkPage = chunkedPostsOfTag.map((chunk, pageIndex) => {
return {
tag: t,
pageCount: allPostsOfTag.length,
posts: chunk,
subPagination: { // equivalent to collection pagination for each tag
pages: Array.apply(null, Array(maxPageOfTag)).map((_, i) => getPageHref(i)),
pageNumber: pageIndex,
pageHref: getPageHref(pageIndex),
previous: pageIndex > 0,
next: (pageIndex + 1) < maxPageOfTag,
href: {
first: getPageHref(0),
previous: pageIndex > 0 ? getPageHref(pageIndex - 1) : null,
current: getPageHref(pageIndex),
next: (pageIndex + 1) < maxPageOfTag ? getPageHref(pageIndex + 1) : null,
last: getPageHref(maxPageOfTag - 1),
},
},
};
});
return chunkPage; // ← chunk with tag, PAGE_SIZE posts and pagination
})
.flat();
return allPostsPerTag;
});
Once the tag collection is ready, the pages can be generated with the simple template [tag.md](https://github.com/aloxe/meinamsterdam/blob/main/src/pages/tag.md)
, where you’ll find back the tag name in the title (revueTags.tag
) and the permalink as well as the page count created above.
---
layout: liste
eleventyExcludeFromCollections: true
eleventyComputed:
title: 'Mot clé : {{ revueTags.tag }}'
permalink: /{{ revueTags.subPagination.pageHref }}
pagination:
data: collections.tags
size: 1
alias: revueTags
---
{{ revueTags.pageCount }} articles sont étiquetés « {{ revueTags.tag }} ».
The liste layout
liste.njk
is a specific layout to display a list of items from a collection. it lists only part of the content, mainly titles and metadata. (see it here)
It takes care of post listing the same way it does with categories. The only difference being where to find posts and the pagination.
Posts by categories are called revue
while the posts by tags are called revueTags
. So both posts and pagination values are initiated on top of the layout.
{% if revue %}
{% set posts = revue %}
{% set paginate = pagination %}
{% else %}
{% set posts = revueTags.pageItems %}
{% set paginate = revueTags.subPagination %}
{% endif %}
The rest of the layout uses these values to list by title and date in a similar way.
It is the same for the navigation. When there are more articles that the PAGE_SIZE
number allowed on a page, the layout will use the pagination data to generate links to the previous and next pages containing the other items.
{% if paginate.href.previous %}
<div class="border-text-light [&&]:text-text-light border-1 rounded-2xl p-1 px-2 block float-left mt-10">
<a href="{{paginate.href.previous}}" class="text-text-light!"> ← billets précédents </a>
</div>
{% endif %}
{% if paginate.href.next %}
<div class="border-text-light [&&]:text-text-light border-1 rounded-2xl p-1 px-2 block float-right mt-10">
<a href="{{paginate.href.next}}" class="text-text-light!"> billets suivants → </a>
</div>
{% endif %}
{% if paginate.pages.length > 1 %}
<div class="[&&]:text-text-light p-1 px-2 center m-auto w-35 mt-10">
Page {{ paginate.pageNumber+1 }} sur {{ paginate.pages.length }}
</div>
{% endif %}
More eleventy features are developped on the next page: Other features.