Как я написал этот блог

May 15, 2020

В этой этой статье я расскажу как написать простой блог на CMS Wagtail с поддержкой языка разметки Markdown.

Просто создание блога рассматривалось в серии статей на Хабре. Подобных материалов много (в основном на английском), но конкретных кейсов с Markdown я нашёл всего... один(?).

Стэк:

Установка Wagtail

Создадим где-нибудь папку для нашего проекта и установим в неё виртуальное окружение.

python3 -m venv env

Теперь активируем окружение и установим нужные пакеты через pip:

source env/bin/activate
pip install wagtail wagtail-markdown

Далее следум документации Wagtail до шага запуска сервера (./manage.py runserver). Здесь мы уже начинаем создавать модели и шаблоны страниц.

Создание страницы и добавление Markdown

Подключим модуль wagtailmarkdown к проекту в mysite/settings/base.py. Нужно вписать его в INSTALLED_APPS в кавычках: 'wagtailmarkdown',.

Заменяем содержимое home/models.py на следующее:

from django.db import models

from wagtail.core.models import Page
from wagtail.admin.edit_handlers import FieldPanel

from wagtailmarkdown.edit_handlers import MarkdownPanel
from wagtailmarkdown.fields import MarkdownField

class HomePage(Page):
    body = MarkdownField(blank=True)

    content_panels = [
        FieldPanel("title", classname="full title"),
        MarkdownPanel("body"),
    ]

Это добавит в модель поля с заголовком и текстом статьи в формате Markdown. Как это работает я опишу подробнее чуть позже.

Теперь нужно добавить это в шаблон страницы home/templates/home/home_page.html:

{% extends "base.html" %}
{% load static %}

{% block body_class %}template-homepage{% endblock %}

{% block extra_css %}
{% endblock extra_css %}

{% block content %}

    {% load wagtailmarkdown %}
    <article>
        {{ self.body|markdown }}
    </article>

{% endblock content %}

Более подробно о способах подключения Markdown написано в документации wagtail-markdown.

Нужно выполнить миграции, чтобы изменения модели отразились в базе данных:

./manage.py makemigrations
./manage.py migrate

Можем запускать сервер и заполнить статью содержимым. В админке уже будет редактор Markdown:

./manage.py runserver

Подсветка синтаксиса кода.

Так как в блоге планируется публикация статей, в которых будет много кода, то для удобства и красоты нужно подсветить синтаксис. Возьмём способ, описанный в доках wagtail-markdown (ссылка выше).

pip install pygments

Затем в консоли Python выполним:

>>> from pygments.formatters import HtmlFormatter
>>> print (HtmlFormatter().get_style_defs('.codehilite'))

Распечатаются стили класса .codehilite. Их надо скопировать из консоли и вставить в новый css-файл. Помимо .codehilite я добавил .codehilitetable и td для стилизации блоков кода с нумерацией строк. Я назвал стиль codehilite.css и поместил в папку mysite/static/css. Если мы хотим, чтобы подсветка работала везде, то этот CSS нужно добавить в базовый шаблон mysite/templates/base.html (можно также подключать отдельный доп. сттиль к каждому шаблону):

{% block extra_css %}
    <link rel="stylesheet" type="text/css" href="{% static 'css/codehilite.css' %}">
{% endblock extra_css %}

Создаём блог

В данный момент можно создавать статичные страницы с содержимым заголовок + текст. Но нам нужен блог. Создаём приложение:

./manage.py startapp blog

Добавляем его в 'INSTALLED_APPS' как ранее подключали wagtailmarkdown.

Далее нужно описать модели для блога в файле blog/models.py. У нас должна быть главная страница блога со списком постов и страница со статьёй. Файл целиком доступен здесь. Ниже приведу его по частям с комментариями.

После импорта необходимых модулей идёт объявление класса BlogIndexPage. Это наша главная страница. В ней содержится только одно поле — intro. Это тот текст, который видно над списком постов. Это поле является экземпляром класса MarkdownField, т.е. мы можем использовать там разметку. Указывая в скобках blank=True мы делаем поле необязательным для заполнения, т.е. оно может оставаться пустым.

from django.db import models

from wagtail.core.models import Page
from wagtail.admin.edit_handlers import FieldPanel
from wagtail.search import index

from wagtailmarkdown.edit_handlers import MarkdownPanel
from wagtailmarkdown.fields import MarkdownField

from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator

class BlogIndexPage(Page):
    intro = MarkdownField(blank=True)

    content_panels = Page.content_panels + [
        FieldPanel('intro', classname="full")
    ]

Далее уже интересней. Здесь происходит сортировка постов и пагинация.

Этот фрагмент кода я взял из документации и немного модифицировал.

Функция get_context() вернёт список только опубликованных статей .live(), которые упорядочены в обратном порядке по дате публикации .order_by('-first_published_at').

    def get_context(self, request):
        # Update context to include only published posts, ordered by reverse-chron
        context = super().get_context(request)
        blogpages = self.get_children().live().order_by('-first_published_at')

Тут начинается пагинация. Опять таки код позаимствован. Если коротко, то здесь выбирается по 5 постов на страницу Paginator(blogpages, 5) и производится обработка исключений (try, except) для правильной навигации.

        paginator = Paginator(blogpages, 5)
        page = request.GET.get("page")

        try:
            # If the page exists and the ?page=x is an int
            posts = paginator.page(page)
        except PageNotAnInteger:
            # If the ?page=x is not an int; show the first page
            posts = paginator.page(1)
        except EmptyPage:
            # If the ?page=x is out of range (too high most likely)
            # Then return the last page
            posts = paginator.page(paginator.num_pages)

        context['posts'] = posts
        return context

Модель страницы блога:

class BlogPage(Page):
    date = models.DateField("Post date")
    intro = models.CharField(max_length=250)
    body = MarkdownField()

    search_fields = Page.search_fields + [
        index.SearchField('intro'),
        index.SearchField('body'),
    ]

    content_panels = [
        FieldPanel("title", classname="full title"),
        FieldPanel('date'),
        FieldPanel('intro'),
        MarkdownPanel("body"),
    ]

Хочу, обратить внимание на content_panels. Здесь меняется представление страницы в админке при редактировании. При этом порядок элементов имеет значение. Сейчас они идут ровно так, как должны быть расположены в шаблоне: заголовок, дата, описание (его мы спрячем) и текст статьи.

В классе BlogPage поле intro задано как CharField, т.е. это простая строка текста безо всякого форматирования. Его можно легко заменить на тот же MarkdownField или RichTextField. Зависит от потребностей. Ричтекст это, грубо говоря, сильно упрощённый и урезанный Markdown.

search_fields я оставил на случай, если позже отребуется реализовать поиск по статьям. Пока не используются. Можно выкинуть как и всё приложение search, которое создаётся вместе с проектом по-умолчанию.

Итак, если файл blog/models.py готов, можем выполнить миграции:

./manage.py makemigrations
./manage.py migrate

Шаблоны

Создаём папкуblog/templates и вложенную blog/templates/blog. В этой папке будут лежать шалоны.

Начинаем с главной страницы блога blog_index_page.html:

{% extends "base.html" %}

{% load wagtailcore_tags %}

{% block body_class %}template-blogindexpage{% endblock %}

{% block content %}
    {# <h1>{{ page.title }}</h1> #}
    <div class="logo"><h1><a href="{{ page.url }}">/blog</a></h1></div>
    <div class="intro">
        {% load wagtailmarkdown %}
        <article>
        {{ self.intro|markdown }}
        </article>
    </div>

    {% for post in posts %}
    <div class="blog-entry">
        <h2><a href="{% pageurl post %}">{{ post.title }}</a></h2>
        <div class="post-intro">{{ post.specific.intro }}</div>
    </div>
    {% endfor %}

<!--Pagination BEGIN-->
    <ul class='pagination-container' {% if posts.paginator.count <= 5 %} style='display:none' {% endif %}>
        {% if posts.has_previous %}
            <li class='pagination'><a href="?page={{ posts.previous_page_number }}">...</a></li>
        {% endif %}
        {% for page_num in posts.paginator.page_range %}
            <li class='pagination'><a class='{% if page_num == posts.number %} active {% endif %}' href="?page={{ page_num }}">{{ page_num }}</a></li>
        {% endfor %}
        {% if posts.has_next %}
            <li class='pagination'><a href="?page={{ posts.next_page_number }}">...</a></li>
        {% endif %}
    </ul>
<!--Pagination END-->

{% endblock %}

Пара слов о пагинации в шаблоне. Блок начинается после <!--Pagination BEGIN-->. Благодаря шаблонизатору Django мы можем использовать в шаблоне условные операторы, циклы, и, практически, писать программу прямо в шаблоне.

Весь блок с навигацией вложен в тег ul (маркированный список). Мы можем прятать навигационные кнопки когда они не нужны и показывать, если надо. В первой строке пагинатора стоит условие:

{% if posts.paginator.count <= 5 %} style='display:none' {% endif %}

Если количество постов меньше или равно пяти, то активируется HTML-код style='display:none', который прячет блок кнопок. Так как на одну страницу выводится 5 постов (мы задавали этот параметр в blog/models.py), то мы не будем видеть кнопку "1", когда в блоге будет меньше постов, чем достаточно для пагинации. Такое решение далеко не самое изящное, по-хорошему писать логику в шаблонах не стоит, особенно, если проект в перспективе будет расти, но для моих целей сойдёт.

Шаблон для страницы поста blog_page.html будет выглядеть так:

{% extends "base.html" %}

{% load wagtailcore_tags %}

{% block body_class %}template-blogpage{% endblock %}

{% block content %}
    <div class="logo"><h1><a href="{{ page.get_parent.url }}">../</a></h1></div>

   <h1>{{ page.title }}</h1>
    <p class="meta">{{ page.date }}</p>
    <div class="page-body">
        {% load wagtailmarkdown %}
        <article>
        {{ self.body|markdown }}
        </article>
    </div>
{% endblock %}

Туда можно добавить кнопку для возврата на родительскую страницу blog_index_page.html:

<p><a href="{{ page.get_parent.url }}">Return to blog</a></p>

Шаг написания основного CSS сайта я опущу.

На этом в принципе всё. У нас есть блог, в котором можно:

Из типичного для блогов:

Могу посоветовать видеоуроки Олега Молчанова. Он расскажет как можно добавить эти блага (как минимум теги и поиск) на сайт.

Наш же блог уже можно размещать на сервере. Об этом есть отдельная статья здесь.

Полезные ссылки

Бонус

Очень быстро добавляем функционал комментариев. Через сервис Disqus. Наверняка видели его на массе сайтов.

Регистрируемся в сервисе. Следуем простым инструкциям. Когда нам предложат способ встраивания комментариев на сайт надо выбрать "универсальный код". Копируем его и вставляем в шаблон blog_page.html. Единственное, что нам нужно сделать это изменить строчку, в которой скрипту Disqus передаётся URL страницы:

this.page.url ="https://blog.rmrf.space{{ page.url }}";  // Replace PAGE_URL with your page's canonical URL variable

Сервис требует, чтобы мы ему передавали URL, а не относительный путь, иначе он вернёт ошибку. Шаблонизатор Django будет подставлять вместо page.url слаг нашей статьи. Второй параметр, который Disqus нам предлагает передавать this.page.identifier можно оставить закомментированным. Готово.

^