Метаданные статьи: заголовок и описание в YAML
В первом уроке мы получили страницу из content/index.md. Текст статьи можно было менять отдельно от программы, однако название вкладки и основной заголовок оставались записанными внутри build.py. Пока страница одна, это удобно. Когда материалов станет несколько, сборщик не должен содержать отдельные условия для каждого названия.
Добавим к знакомому исходнику метаданные — небольшое описание документа, которое программа прочитает отдельно от основного текста. В этом уроке ими будут название и краткое описание страницы. Результат остаётся прежним: сборщик создаёт dist/index.html. Изменится источник данных для его title, H1 и meta description.
Два вида информации в одном файле
Содержание статьи отвечает на вопрос, что увидит читатель внутри страницы. Метаданные сообщают, как эту страницу назвать и с какими настройками собрать. Такое разделение есть в ProfessorWeb: начало Markdown-файла содержит параметры материала, а общий шаблон использует их при формировании документа. Основной заголовок поэтому не приходится повторять в самом тексте.
Возьмём content/index.md в состоянии после первого урока. Сохраните его заголовки, список и пример Python. Перед всем текстом добавьте следующий блок:
---
title: Статический сайт из Markdown
description: Учебный сайт из Markdown.
---
После второй строки --- оставьте пустую строку, а затем прежний ## Первый материал. Первый абзац сохраняется в последней редакции:
Текст статьи хранится в **Markdown**. Чтобы обновить страницу, измените исходник и повторите сборку.
Две строки --- ограничивают блок параметров. Такой начальный блок часто называют front matter. Внутри мы используем YAML: имя свойства, двоеточие, пробел и значение. Для нашего сборщика это соглашение о формате исходника, а не особая возможность браузера. Обычная функция преобразования Markdown сама по себе не знает, что title нужно отправить в HTML-элемент title.
Поэтому недостаточно передать новый файл в прежнюю программу. Она обработает и параметры как часть статьи. Мы сначала отделим начальный блок, затем разберём YAML и только после этого преобразуем оставшийся Markdown. Разделительная строка --- где-нибудь внутри статьи останется частью содержания: программа ищет границу метаданных только после первой строки файла.
Название и описание пока обязательны. Даже для одной страницы это полезное правило: пропущенное описание обнаружится во время сборки, а не после размещения сайта. Позднее к тому же словарю добавятся адрес, рубрика и флаги публикации. Эти свойства не требуют другого языка или отдельного файла на каждый материал.
Чтение метаданных
Для разбора YAML добавим PyYAML. Замените весь requirements.txt следующим содержимым:
Markdown==3.11
PyYAML==6.0.3
В отдельном учебном проекте зависимости устанавливаются прежней командой:
.venv/bin/python -m pip install -r requirements.txt
Теперь замените весь build.py. Это полный файл для одной страницы; функций из следующих уроков ему пока не требуется:
from html import escape
from pathlib import Path
import markdown
import yaml
ROOT = Path(__file__).resolve().parent
DIST = ROOT / "dist"
def render_markdown(text):
return markdown.markdown(
text,
extensions=["fenced_code"],
output_format="html",
)
def read_document(path):
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
if not lines or lines[0].rstrip("\r\n") != "---":
raise ValueError(f"{path.name}: нет начального блока YAML")
end = next(
(index for index, line in enumerate(lines[1:], 1)
if line.rstrip("\r\n") == "---"),
None,
)
if end is None:
raise ValueError(f"{path.name}: нет закрывающей строки ---")
metadata = yaml.safe_load("".join(lines[1:end]))
if not isinstance(metadata, dict):
raise ValueError(f"{path.name}: YAML должен содержать словарь")
for name in ("title", "description"):
value = metadata.get(name)
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{path.name}: {name} должен быть непустой строкой")
text = "".join(lines[end + 1:])
return {
**metadata,
"source": path,
"text": text,
"body": render_markdown(text),
}
def write_page(page, navigation=""):
title = escape(page["title"], quote=True)
description = escape(page["description"], quote=True)
body = page["body"]
html = f"""<!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>
<main>
<h1>{title}</h1>
{body}
</main>
</body>
</html>
"""
(DIST / "index.html").write_text(html, encoding="utf-8")
def main():
document = read_document(ROOT / "content" / "index.md")
DIST.mkdir(exist_ok=True)
write_page(document)
print("Создан dist/index.html")
if __name__ == "__main__":
main()
В предыдущем примере чтение, преобразование и запись шли подряд. Здесь они разделены на функции по назначению. read_document принимает путь к исходнику и возвращает словарь документа. render_markdown превращает текст в HTML-фрагмент. write_page получает готовые данные и создаёт полный HTML. Небольшая main связывает эти действия в прежнюю последовательность. Это разделение понадобится, когда по той же цепочке будут проходить разные статьи.
Разберём чтение чуть подробнее. splitlines(keepends=True) делит файл на строки, сохраняя их окончания. Благодаря этому обратное объединение не склеивает соседние абзацы. Проверка первой строки устанавливает, что файл действительно начинается с нашего блока метаданных. Программа затем ищет следующую самостоятельную строку ---. Если её нет, весь документ нельзя молча считать YAML: это ошибка исходника.
Срез lines[1:end] содержит только параметры, без ограничителей. yaml.safe_load переводит их в обычные Python-объекты. Мы используем безопасный вариант загрузчика, не предназначенный для создания произвольных Python-объектов по YAML-тегам. Его назначение описано в документации PyYAML. После разбора дополнительно проверяем результат: программе нужен словарь свойств, а YAML допускает также списки, строки и другие значения.
Оператор **metadata сохраняет все заданные параметры в возвращаемом словаре. Поля source, text и body назначает сама программа: первое хранит путь к исходнику, второе — Markdown без параметров, третье — преобразованный HTML. В частности, page["title"] и page["body"] относятся к разным стадиям подготовки одной страницы. Это не два независимых документа.
Текстовое значение и HTML-разметка
Один и тот же заголовок используется в двух местах. Элемент title задаёт название вкладки, а H1 показывает основное название внутри страницы. Краткое описание помещается в атрибут content элемента meta. Здесь значения должны оставаться текстом, даже если автор использовал кавычки, знак амперсанда или угловые скобки.
Поэтому перед подстановкой вызывается escape(..., quote=True). Например, символ & превращается в &, а кавычка — в ". В браузере читатель увидит исходный символ, но он не изменит структуру атрибута. Поведение этой функции определено в документации модуля html.
У body другая роль: он уже содержит разметку, созданную преобразователем Markdown. Если экранировать весь body, браузер покажет буквальный текст <p>, а не абзац. Поэтому мы экранируем название и описание, а результат Markdown вставляем как HTML. При этом пример предполагает, что исходники пишет владелец сайта. Обработка недоверенного содержания от посетителей требует отдельной очистки HTML; загрузчик YAML эту задачу не решает.
Чтобы увидеть различие, временно измените параметры знакомого документа:
title: 'Markdown & HTML: первый материал'
description: 'Страница с примером "Профессор Веб".'
После запуска .venv/bin/python build.py ожидается следующий фрагмент результата:
<title>Markdown & HTML: первый материал</title>
<meta name="description" content="Страница с примером "Профессор Веб".">
В самой странице заголовок останется читаемым: Markdown & HTML: первый материал. Кавычки вокруг слов «Профессор Веб» не закроют атрибут описания раньше времени. Имена title и description при этом не должны появиться отдельными строками в содержании статьи.
Ошибочные параметры и состояние проекта
YAML различает текст, числа и логические значения. Если написать title: 123, загрузчик вернёт число, а наш код остановится с сообщением, что название должно быть непустой строкой. Название "123" в кавычках будет текстом и допустимо. Строка из одних пробелов тоже отклоняется: strip() используется для проверки содержательности значения.
В значении с двоеточием и пробелом лучше явно поставить кавычки, как в предыдущем примере. Для метаданных статьи это простое правило уменьшает число синтаксических ошибок. Файл с неправильными отступами или незакрытой кавычкой разбирается самим PyYAML и завершает сборку исключением. Мы не заменяем ошибку пустым описанием: скрытая потеря данных затруднила бы поиск причины.
Обратите внимание на порядок main: сначала полностью читается документ, затем создаётся и записывается результат. Если обязательное свойство отсутствует, запись нового HTML не происходит. Старый dist/index.html при этом может остаться от предыдущего запуска. Наличие файла ещё не доказывает успешность последней сборки: нужно учитывать сообщение программы и время изменения результата. В следующем уроке мы будем собирать несколько страниц и отдельно решим проблему устаревших файлов.
Для самостоятельного сравнения поменяйте только description, повторите сборку и откройте исходный HTML. Основной текст статьи должен остаться прежним, а атрибут описания — измениться. Так можно наблюдать две независимые части одного исходника: параметры влияют на оболочку, Markdown — на содержимое.
Перед продолжением верните title: Статический сайт из Markdown и description: Учебный сайт из Markdown.. Сборщик всё ещё знает конкретный файл content/index.md и конкретный результат dist/index.html, но название и описание уже принадлежат документу. Теперь можно перенести в метаданные и публичный адрес, чтобы добавление другой статьи не требовало отдельной программы.