skip to main content

Building a minimal blog with Lektor

How I built and customized my static blog with Lektor.

Why Lektor?

As a Python enthusiast, I had to choose between Pelican and Nikola and the more recent ones, Urubu and Lektor. As I explored these four static site generators, I realized all of them were very capable and powerful in their own ways.

However, Lektor felt the right fit for me for the following reasons.

Simple folder hierarchy

Folder hierarchy:

assets
content
    about
        contents.lr
    blog-post-1
        contents.lr
models
    blog.ini
    blog-post.ini
templates
    layout.html

These are the key folders:

  1. assets folder has styles and images
  2. the content folder has articles and sub sections
  3. models contain data models which can create a type of page or a set of pages and set attributes for pages
  4. templates contain HTML templates and macros for reusable HTML fragments

The contents.lr files at the root of each folder are just markdown files that Lektor expects to build into the index.html for that folder.

No language based configuration

As a developer, I don't mind messing with code and language based configuration files. As a content author, I want to deal only with flat files. I do not want to mess around with code.

There is an obvious overlap between the personas, a developer, and a content author since often you fix some feature to accommodate some new content or a scenario for a new article. Since this happens quite often, I handle this as a workflow task rather than mixing both personas.

Data modeling

Data models allow us to create a representation of a specific HTML output. Each model will have a few base fields like the name and description. We can extend the models somewhat to create systems like navigation, pagination, and possibly even more.

We will see how models are used when we walk through the configuration and customization of the static site .

Convenient CLI tool

Lektor comes with a neat CLI tool that helps us create, build, serve and deploy projects. Lektor CLI has fewer commands than its counterparts.

Type lektor and see the available commands. I use two commands most of the time, lektor server, to serve the content locally, and lektor build, to download packages if needed and build the content.

Each command comes with a good amount of documentation. Simply append --help at the end of a command like this:

lektor build --help

This gives a nice help text with all the switches you could use with the lektor build command.

Building a static site with Lektor

Setup

Install lektor from the command line by pasting the following command. This will collect and build all the dependencies necessary for Lektor.

curl -sf https://www.getlektor.com/install.sh | sh

Create a new project

lektor quickstart

Step 1:
> Project Name: animeshdotblog-model

Step 2:
> Author Name [Animesh,,,]: Animesh Bulusu

Step 3:
> Project Path [/home/animesh/projects/animeshdotblog-model]: 

Step 4:
> Add Basic Blog [Y/n]: Y

That's all. Create project? [Y/n] Y

It will ask you the project name, author name, project path and if you want to add a basic blog. I chose the project name animeshdotblog-model and it will automatically assume the project path will be a folder with the project name the current path. Of course, you can change it.

Configuration

Change into the new project path and open the editor.

cd animeshdotblog-model
code .

Init a git repository, add a .gitignore file and add the /public to it. By default, the /public folder holds the artifacts after the build and we don't need it in version control. Add and commit all the files.

git init
touch .gitignore
echo "/public" > .gitignore
git add .
git commit -m "First commit"

Start the website.

lektor server 

This will run the site on port 5000. You could specify a port by adding -p PORT at the end. To automatically open the browser after serving the site, add --browse at the end.

Lektor first runFirst Run

The landing page is generated from the contents.lr file at the root of content folder. Inside the content folder, you also have folders for about, blog and projects with a contents.lr file inside each of them. As explained earlier, these contents.lr files at the root of each folder are just markdown files that Lektor expects to build into the index.html for that folder.

The about/contents.lr file is built into about/index.html so that when you visit http://localhost:5000/about you would see the contents of your about page. Pretty easy and intuitive right?

The blog is accessible at the URL http://localhost:5000/blog. For my domain https://animesh.blog/ having a blog like this would give the blog a URL https://animesh.blog/blog, which is redundant. I felt it best to move the blog to the root of the site.

To do this, first update the root contents.lr as such:

_model: blog

title: Home

Then, update the blog model in blog.ini by adding the following in the pagination section:

items = this.children.filter(F._model == 'blog-post')

You will now see the contents of about and projects on the landing page. This is because they are inside the root folder and its contents.lr has the model set to blog.

Then, move the folder blog/first-post into the content. Now you can see the post 'Hello Website' also appears on the landing page.

Set the _model of about and projects folders in their contents.lr file.

_model: page

title: About

Finally, update the contents of nav tag in layout.html to this:

<li{% if this._path == '/' %} class="active"{% endif
    %}><a href="{{ '/'|url }}">Home</a></li>
{% for href, title in [
    ['/projects', 'Projects'],
    ['/about', 'About']
] %}
    <li{% if this.is_child_of(href) %} class="active"{% endif
    %}><a href="{{ href|url }}">{{ title }}</a></li>
{% endfor %}

Now this is your landing page:

Blog in the landing pageBlog in the landing page

Title on the browser tab now shows up as — | animeshdotblog-model. You can fix this by updating the blog model blog.ini:

[model]
name = Blog
label = {{ this.title }}
hidden = yes

[fields.title]
label = Title
type = string

We have added a new field title and updated the label attribute in [model] section to {{ this.title}}. I am not really sure why the title is empty when it is just label = Blog.

Now we can move onto writing our first post.

Authoring

Click the pencil icon in the corner to navigate to the admin.

Lektor adminLektor admin

Lektor has a very minimalistic admin designed around the actions necessary for the current view. Lektor admin doesn't support theming at the moment, but it gets its job done. You can see the sub pages of the root About, Hello Website and Projects. Though Hello Website is the only blog post, it still shows the other sub folders of the root.

Click on the plus icon beside the Home link in the top left corner to add a blog post. Enter the title of the post and the ID (id or URL or slug) field automatically builds off of the title. Click on the Add Child Page button to open the post for editing.

Lektor admin - add postAdd post

This is the edit view of the post just created:

Lektor adminEdit post

Fill in your author and twitter handle. Unless your template displays these two fields you don't have to fill them. Select publication date and click on Save Changes. Your post is now created and you will be shown the post preview page inside admin.

Lektor adminPreview post

Now check your landing page again. You have a new post:

Landing pageNew post

Customization

The quickstart that Lektor provides is an extremely simple website. We made a few modifications to this to understand how the content is used and organized by Lektor. Let us make a few customizations.

A few customizations below use Lektor plugins.

Post Summary

Initially, I used to have only the post title in my static site.

Simplest landing pageSimplest landing page

I wanted a summary of the post below the post title, generated automatically, in this fashion.

My landing pageSimple landing page

I found a plugin for this called markdown-excerpt which will extract the first paragraph from the body of the post. We have to update the part where the body of a post is rendered in the blog.html macro or wherever you have the blog post template with the filter excerpt like this:

{{ post.body|excerpt }}

When I started importing old posts from my previous blogs, I found that some posts did not have a good first paragraph. To change the first paragraphs for all these posts is tedious and some posts have to be adjusted for the change in the first paragraph.

Though it is not a big deal, this also means that {{ post.body|excerpt }} is parsing the entire posts' body N times on my landing page, given N posts on the landing page. It felt a bit excessive. I wanted another option.

I created a new field, excerpt in the blog post model blog-post.ini:

[fields.excerpt]
label = Excerpt
type = markdown
size = normal

This creates a new field that can accept markdown text.

ExcerptNew excerpt field in admin

Add some text there and save the post. The contents.lr for the post has an updated field now

excerpt: First Post

Then surround the {{ post.body }} part in the blog.html macro with the following HTML:

{% if from_index %}
{{ post.excerpt }}
{% else %}
<div>
    {{ post.excerpt }}
</div>
<div>
    {{ post.body }}            
</div>
{% endif %}

That is all. You have posts with excerpts.

Simplified landing pagePosts with excerpts

RSS Feed

Every blog needs an RSS feed. There is a plugin lektor-atom to do this.

To add a plugin to the site, run the following command from command line:

lektor plugins add lektor-atom

The plugin can also be added manually in the project file animeshdotblog-model.lektorproject:

[packages]
lektor-atom = 0.2

Stop the running Lektor instance by pressing Ctrl-C and run the lektor server command again.

Create a new folder configs in the root and create a file atom.ini inside it. Add the following lines to atom.ini

[blog]
name = animeshdotblog-model
source_path = /
url_path = /feed.xml
items = site.query('/')
item_model = blog-post

Now you will notice the following error in the terminal:

E feed.xml (RuntimeError: To use absolute URLs you need to configure the URL in the project config.)

To fix this, you must supply an absolute URL in your project file:

url_style = absolute
url = https://animeshb.github.io

Finally, add a list element in the nav tag.

<li><a href="/feed.xml">RSS</a>

Table of Contents

A table of contents is useful for long articles like this one. There is an official plugin for this called markdown-header-anchors.

Install the plugin:

lektor plugins add markdown-header-anchors

Create a new post with a few headers:

TOC demoTOC demo before

Place this piece of HTML in your blog.html macro somewhere above the {{post.body}}.

{% if not from_index %}
    {% if post.body.toc %}
        <div class="toc-container">
            <p>contents</p>
            <ul class="toc">
                {% for item in post.body.toc recursive %}
                    <li><a href="#{{ item.anchor }}">{{ item.title }}</a>
                        {%if item.children %}
                            <ul>{{ loop(item.children) }}</ul>
                        {% endif %}
                    </li>
                {% endfor %}
            </ul>
        </div>
    {% endif %}
{% endif %}

Refresh the blog post and now you have a table of contents:

TOC DemoTOC demo after

Categories

The official guide does a good job explaining how to go about setting up categories for your projects. We can easily adapt this for blog posts.

First, we need to create two new models, one for blog categories blog-categories.ini which will represent all categories available and the other for blog category blog-category.ini which will represent the individual category. Blog categories model will be the parent of blog category.

blog-categories.ini

[model]
name = Blog Categories
label = Blog Categories
hidden = yes
protected = yes

[children]
model = blog-category
order_by = name

blog-category.ini

[model]
name = Blog Category
label = {{ this.name }}
hidden = yes

[fields.name]
label = Name
type = string

[children]
replaced_with = site.query('/').filter(F.categories.contains(this))

Since Lektor considers any sub folder under current folder as its child, categories would not have its associated posts as its children by default. To get around this, we use an attribute replaced_with in the [children] section which makes a query to the landing page path and returns a set of items that have this category.

Next, we update the blog post model blog-post.ini to have a new field, categories, so that it shows up in the admin when a post is in the edit view.

[fields.categories]
label = Categories
type = checkboxes
source = site.query('/blog-categories')

Here we are using site.query to fill the categories from the folder blog-categories.

Now that models are ready, let us create a folder blog-categories and set its contents.lr as such:

_model: blog-categories

_slug: /categories

The _slug will be /blog-categories by default, but you can change it to categories to have a simpler URL. Go to admin and now you can see a sub page for categories.

CategoriesBlog Categories link on the left

Click on it and you can see that it is just a regular page on the site.

CategoriesCategories Page Edit View

Click on the plus sign on the top left to add a sub page to Blog Categories

Add CategoryAdd Category

Add a few more categories in this way and you can see them on the side. After adding each category we see an error like this:

Categories ErrorTemplate not found

This is because we do not have templates setup for blog category and categories.

Before adding the templates, we need to add two macros, one to fetch a list of posts belonging to a category, render_post_list, and the other to render a list of all categories available, render_cat_nav. Save them into a new categories.html in the templates/macros folder.

{% macro render_post_list(posts) %}
<ol>
{% for post in posts.order_by('-pub_date') %}
<li><a href="{{ post|url }}">{{ post.title }}</a></li>
{% endfor %}
</ol>
{% endmacro %}

{% macro render_cat_nav(active=none) %}
{% set post_count = site.query('/blog-categories').count() %}
<h2>{{post_count}} Categories</h2>
<div>
<ul>
{% for category in site.query('/blog-categories') %}
    <li{% if category._id == active %} class="active"{% endif
    %}><a href="{{ category|url }}">{{ category.name }}</a>
    ({{ category.children.count() }})</li>
{% endfor %}
</ul>
</div>
{% endmacro %}

In macros, we can use the set statement to assign a variable to an expression. In the render_cat_nav macro, we get the count of all categories available. In the render_cat_nav macro, while looping over the available posts in the current category, we are also getting a count of the available posts for that category.

Add two templates blog-categories.html and blog-category.html. After adding these two empty templates, we don't see the error anymore. We need to inherit from layout.html and update them as follows.

blog-categories.html

{% extends "layout.html" %}
{% from "macros/categories.html" import render_cat_nav %}
{% block title %}Tags {{ super.title }}{% endblock %}
{% block body %}
{{ render_cat_nav(active=none) }}
{% endblock %}

blog-category.html

{% extends "layout.html" %}
{% from "macros/categories.html" import render_post_list %}
{% block title %}Category {{ this.name }} {{super.title}}{% endblock %}
{% block body %}
<h2>Category: {{ this.name }}</h2>
<h4>Posts</h4>
{% if this.children %}
    {{ render_post_list(this.children) }}
{%else%}
    <p>No posts in category {{this.name}} yet. May be there is a draft in the works.</p>
{% endif %}
<br>
<p>
    <a href="/categories">Show all Categories</a>
</p>
{% endblock %}

The code render_post_list(this.children) in blog category template passes a list of children to the render_post_list macro which just loops over and displays them.

To have a list of categories inside a blog post, add the following piece somewhere in your blog.html macro.

{%if post.categories %}
    <div>Categories:
        <ul>
        {% for category in post.categories  %}
            <li><a href="/categories/{{ category }}">{{category}}</a>{% if not loop.last %},{% endif %}</li>
        {% endfor %}
        </ul>
    </div>
{%endif%}

Open up the blog posts in admin and select categories as needed and save them.

Categories on postCategories on post

Also, add the categories link in the navigation. Now you have the categories ready.

Categories PageCategories Page

Click on any category to see the list of posts in that category.

Category PageSpecific category

Comments

I had setup comments on my static site using the official plugin lektor-disqus. It is fairly easy to setup comments once you have your account ready with Disqus. Identify the short name from the Disqus admin and add into the config file for it as per the documentation.

I have removed the comments recently as I am experimenting with having comments using gitlab issues API.

Workflow

Below is the workflow I follow for my static site.

Editor

I use VS Code for writing posts on my static site.

VS Code is a fantastic editor that was needed on Linux. I have been using Visual Studio Code since day 1 of its release. I used Adobe brackets for almost two and half years for all my frontend editing needs, but ever since Visual Studio caught up to Brackets in that aspect, I made it my sole editor.

I still think Brackets is a great editor, but I can't miss the brilliance of VS Code. Now that almost all the plugins exist for VS Code, there is no need to look back. VS Code has a fantastic release cadence and brings great features with each release. What's not to like?

I just wish there was more support for templating languages like Jinja2, Django. I manually indent code whenever HTML is mixed with templating languages.

I set a build task for this site to run the command lektor server. For Visual Studio users, Ctrl-Shift-B is the build command. VS Code has the same action for this keybinding. When you press Ctrl-Shift-B for the first time in a project, VS Code will prompt you to configure a build task.

VS CodeVS Code Configure Build Task

Press Configure Build Task and select the Others task runner for manual configuration and add the following configuration.

{
    "version": "2.0.0",
    "tasks": [
        {
            "taskName": "lektor-run",
            "command": "lektor",
            "type": "shell",
            "args": ["server"],
            "group": {
                "kind": "build",
                "isDefault": true
            }
        }
    ]
}

If you want to serve on another port, update the args property like this: ["server", "-p", "5001"]. That is it, your Ctrl-Shift-B is ready.

Version Control

I do content authoring on and push commits off the master branch. I also use another experimental branch to add new features to the static site. I try not to do major style changes on the experimental branch to reduce merge conflicts.

Here are my commits for this quickstart example. I group changes of one feature into a commit:

Aug 15, 2017    Configure build task
                Add categories and update posts with categories
Aug 14, 2017    Add table of contents to posts that have headings
                Add atom feed
                Add post excerpt
Aug 13, 2017    Initial configuration
                First commit

I use gitlab for hosting my git repository. Every push triggers a CI job which builds and deploys the content to the server.

If you decide to use Gitlab, you can learn the process of deploying to Gitlab pages using these two articles.

  1. Getting Started with Gitlab Pages
  2. Lektor with Gitlab Pages

Website Checklists

Once the job finishes, I verify the blog manually on the desktop and one or two mobiles and the Chrome emulator. After this, I use a few tools to make sure the performance is good and the mobile friendliness is intact.

  1. Google Pagespeed Insights
  2. Varvy
  3. GTMetrix
  4. WebPageTest

Based on the advice from these sites, I adjust things to control site performance after the push of a new article and/or a feature.

References

This example

The quickstart example, animeshdotblog-model, we worked on in this post is available at:

github: animeshdotblog-model

This quickstart example now can be viewed at:

https://animeshb.github.io/

I have setup Github pages on it and updated it as per Lektor's official documentation on deploying to Github pages.

My static blog

Repository for my static blog, animesh.blog, is at:

gitlab: aniemsh.gitlab.io