Перейти к содержанию

Общие шаблоны страницы: единые хедер и футер

После предыдущего урока сборщик создаёт два документа с постоянными адресами. Обе страницы получают одну и ту же HTML-оболочку, но эта оболочка пока записана в Python. Добавление меню, общего футера или другого расположения заголовка заставляет менять программу, хотя чтение Markdown и запись файлов остаются прежними.

Перенесём оформление в отдельные шаблоны. У каждой статьи по-прежнему будут собственные название, описание и текст, а общие хедер и футер будут редактироваться в одном месте. Такой подход понадобился и при обновлении ProfessorWeb: прежние материалы и новый каталог должны пользоваться одними общими блоками, чтобы изменения оформления распространялись на весь сайт.

Разделение содержимого и оболочки

Шаблон — HTML-исходник, в котором оставлены места для подстановки значений. Например, вместо конкретного названия страницы можно написать {{ title }}. Во время сборки программа подставит туда название текущего документа. Браузер получит обычный HTML без необходимости разбирать шаблон.

В нашем случае удобно разделить оболочку на три файла. page.html задаёт структуру документа и положение содержимого. header.html хранит бренд и основную навигацию. footer.html содержит общий завершающий блок. Названия этих файлов фиксированы в программе: мы пока не добавляем выбор произвольного шаблона из YAML.

Создайте папку layouts рядом с content и build.py. Новый файл layouts/page.html должен содержать весь следующий текст:

<!doctype html>
<html lang="ru">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{{ title }}</title>
  <meta name="description" content="{{ description }}">
</head>
<body>
  {{ header }}
  <main>
    <h1>{{ title }}</h1>
    {{ body }}
  </main>
  {{ footer }}
</body>
</html>

Здесь {{ body }} — уже преобразованная статья, а {{ header }} и {{ footer }} — подготовленные общие фрагменты. Обратите внимание на H1: он остаётся в оболочке и берёт название из метаданных. В content/index.md и content/second.md дополнительный заголовок первого уровня не требуется.

Полный layouts/header.html:

<header>
  <a href="/index.html">Профессор Веб</a>
  <nav aria-label="Основная навигация">
    <a href="/index.html">Главная</a>
    <a href="/articles/second.html">Вторая статья</a>
  </nav>
</header>

Полный layouts/footer.html:

<footer>
  <p>Учебный сайт «Профессор Веб».</p>
</footer>

Адреса в меню — уже существующие адреса двух документов. В дальнейшем каталог сможет порождать более подробную навигацию. Пока два явных перехода позволяют увидеть, что один и тот же хедер действительно попадает на обе страницы.

Шаблоны задают разметку, а не окончательное визуальное оформление. На этом этапе меню будет отображаться средствами браузера без дополнительного CSS. Следующий урок добавит общие стили и изображения. Это полезная последовательность: сначала проверяем, что страницы используют одну структуру, затем оформляем её одним общим файлом стилей.

Подстановка значений в исходник

Для такого примера не требуется язык шаблонов с условиями и циклами. Достаточно найти обозначения {{ имя }} в исходном файле и заменить их значениями из словаря. Мы напишем маленький собственный обработчик; сходство обозначений с другими шаблонными языками не делает его Jinja.

В build.py добавьте import re. Полный блок импортов после изменения:

from html import escape
from pathlib import Path
import re
import shutil
from urllib.parse import urlsplit

import markdown
import yaml

После определений ROOT и DIST добавьте:

TOKEN = re.compile(r"\{\{\s*([a-z_]+)\s*\}\}")

Перед write_page поместите новую функцию целиком:

def substitute(source, context):
    def replace(match):
        name = match.group(1)
        if name not in context:
            raise ValueError(f"Неизвестный токен шаблона: {name}")
        return context[name]

    return TOKEN.sub(replace, source)

Регулярное выражение распознаёт пару фигурных скобок с каждой стороны и имя из строчных латинских букв либо подчёркивания. \s* разрешает пробелы вокруг имени: {{title}} и {{ title }} имеют одинаковый смысл. Круглые скобки выделяют само имя, которое затем берётся через match.group(1).

Метод sub вызывает функцию replace для каждого совпадения в исходной строке. Словарь context связывает имена с уже подготовленными строками. Если в шаблоне написать {{ tile }} вместо {{ title }}, обработчик сообщит об неизвестном токене. Он не заменит ошибку пустой строкой, которая могла бы остаться незамеченной в документе.

Мы не используем содержимое метаданных как выражение Python и не выполняем текст после {{. Здесь нет обращения к атрибутам, вызова функций, условий или циклов. Например, {{ page.title }} не входит в заданный синтаксис. Такой шаблон нужно исправить на {{ title }}, а не ожидать от нашего обработчика возможностей полноценного языка.

Это ограничение делает механизм обозримым: у каждого обозначения есть конкретное строковое значение, а у каждой страницы — определённое место его подстановки. Когда структуре сайта потребуются списки, их можно сначала сформировать отдельной функцией и передать в шаблон уже готовый HTML. В следующих уроках так будут работать каталог и переходы между уроками.

Общие фрагменты и готовая страница

Теперь добавьте после substitute функцию render_layout. Это полный новый код:

def render_layout(context):
    layouts = ROOT / "layouts"
    header = (layouts / "header.html").read_text(encoding="utf-8")
    footer = (layouts / "footer.html").read_text(encoding="utf-8")
    page = (layouts / "page.html").read_text(encoding="utf-8")
    values = {
        **context,
        "header": substitute(header, context),
        "footer": substitute(footer, context),
    }
    return substitute(page, values)

Сначала читаются три исходника. Хедер и футер получают тот же контекст, что и страница: если позже добавить в них {{ title }}, отобразится название текущего материала. Затем их подготовленные строки дополняют исходный словарь под ключами header и footer. Наконец, подстановка выполняется в page.html.

Это два явных этапа для разных исходников, а не повторная обработка уже готового HTML. Функция не ищет произвольные включения внутри результата. Хедер не может бесконечно подключать самого себя, а Markdown не может незаметно приказать сборщику прочитать другой шаблон.

Пока шаблоны читаются для каждой страницы. Это легко понять и удобно при изучении. У большого сайта можно читать неизменные файлы один раз за сборку и передавать их обработчику. Такая оптимизация меняет число операций чтения, но не правило: обрабатываем известный исходник, подставляем подготовленные значения и получаем результат.

Полностью замените прежнюю write_page следующим кодом:

def write_page(page, navigation=""):
    context = {
        "title": escape(page["title"], quote=True),
        "description": escape(page["description"], quote=True),
        "body": page["body"],
    }
    output = DIST / permalink_to_path(page["permalink"])
    output.parent.mkdir(parents=True, exist_ok=True)
    output.write_text(render_layout(context), encoding="utf-8")

Строки title и description экранируются так же, как в предыдущих уроках. В body сохраняется HTML-фрагмент. Важно делать это в одном месте: шаблон отвечает за расположение, а write_page — за подготовку значений и запись по постоянному адресу. Если заголовок содержит & или кавычку, правило его обработки не зависит от того, в какой из трёх файлов он попал. Работа escape описана в документации Python.

Аргумент navigation пока имеет пустое значение и не используется. Он подготовлен для последующего урока о последовательности страниц; вызов write_page(document) работает без дополнительного аргумента. Функции чтения документов, проверки маршрутов и преобразования Markdown остаются прежними.

main тоже не меняется: она сначала получает публичные документы, пересоздаёт только учебную dist и записывает каждую страницу. Новое оформление не меняет permalink, не переименовывает исходники и не добавляет самостоятельные копии статьи.

Пример кода внутри Markdown

У учебного сайта есть особое требование: статьи могут объяснять сам шаблонизатор. Значит, автор вправе показать текст {{ title }} как часть примера. Если после вставки статьи обработать весь HTML ещё раз, этот пример неожиданно превратится в название документа.

Временно добавьте в конец content/second.md следующий фрагмент:

### Обозначение в шаблоне

```html
<h1>{{ title }}</h1>
```

После преобразования Markdown угловые скобки внутри блока будут представлены HTML-сущностями. Принципиальный фрагмент результата должен содержать:

<pre><code class="language-html">&lt;h1&gt;{{ title }}&lt;/h1&gt;
</code></pre>

Наш substitute видит обозначения из page.html, включая {{ body }}. Взамен этого обозначения он возвращает готовую строку статьи. Совпадения внутри возвращённой строки не становятся новыми заданиями на замену. Поэтому в блоке кода остаётся буквальное {{ title }}, а настоящий H1 страницы содержит «Вторая статья».

Не добавляйте в программу цикл «подставлять, пока остались фигурные скобки». Для сайта о программировании это меняет содержание уроков и может вызвать ошибку на совершенно допустимом примере. По той же причине обычный текст метаданных не должен интерпретироваться как второй шаблон.

Изменение одного общего блока

Запустите сборку и откройте оба адреса через локальный HTTP-сервер:

.venv/bin/python build.py
.venv/bin/python -m http.server 8000 --bind 127.0.0.1 --directory dist

Если сервер уже работает в другом окне, повторно запускать его не требуется: после сборки достаточно обновить страницы. На /index.html и /articles/second.html ожидаются одинаковые бренд, меню и футер. Названия и тело статей будут разными.

Измените только абзац в layouts/footer.html:

<p>Учебный сайт «Профессор Веб». Материалы обновляются из Markdown.</p>

После повторной сборки эта строка должна появиться на обеих страницах. Ни content/index.md, ни content/second.md для такого изменения редактировать не нужно. Если поменять только исходный шаблон и обновить браузер без сборки, готовые страницы сохранят прежний футер: как и раньше, браузер читает результат из dist.

Для сравнения можно временно изменить в page.html токен {{ title }} на {{ tile }}. Ожидается ошибка Неизвестный токен шаблона: tile. Исправьте имя перед продолжением. Такая ошибка может возникнуть уже после очистки dist, поэтому неполный результат нельзя считать готовым выпуском сайта.

В завершение верните исходный абзац футера и удалите временный раздел «Обозначение в шаблоне» из второй статьи. Мы сохраняем одинаковые учебные данные для следующих шагов. Теперь у проекта есть общая оболочка, два самостоятельных документа и закреплённые адреса. Добавление стилей и изображений будет происходить через общие ресурсы, а не через копирование оформления в каждую статью.