Общие шаблоны страницы: единые хедер и футер
После предыдущего урока сборщик создаёт два документа с постоянными адресами. Обе страницы получают одну и ту же 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"><h1>{{ title }}</h1>
</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, поэтому неполный результат нельзя считать готовым выпуском сайта.
В завершение верните исходный абзац футера и удалите временный раздел «Обозначение в шаблоне» из второй статьи. Мы сохраняем одинаковые учебные данные для следующих шагов. Теперь у проекта есть общая оболочка, два самостоятельных документа и закреплённые адреса. Добавление стилей и изображений будет происходить через общие ресурсы, а не через копирование оформления в каждую статью.