Последовательность уроков и пагинация
Каталог уже показывает три статьи нашего учебного сайта. Их можно отбирать по теме или проекту, однако это ещё не учебная последовательность. Для серии важно, чтобы следующий материал продолжал пример предыдущего. Алфавит названий и порядок файлов на диске не выражают такую связь.
В ProfessorWeb уроки объединены в последовательности, а переходы формируются общими шаблонами. Сделаем небольшую версию того же механизма: отдельный YAML-файл задаст порядок трёх демонстрационных страниц, сборщик определит действительных соседей, а шаблон выведет переходы. Мы рассматриваем пагинацию между уроками; разбиение длинного каталога на страницы по двадцать карточек — другая задача.
Порядок как самостоятельные данные
Создайте папку data и сохраните в data/collections.yaml полный файл:
first-course:
items:
- /index.html
- /articles/second.html
- /my/legacy/first.php
first-course — идентификатор учебной последовательности. Значения items являются постоянными адресами уже существующих документов. Это порядок страниц маленького сайта, который мы строим в примерах, а не список двенадцати статей нашей серии на ProfessorWeb. Никаких новых копий содержания из-за добавления YAML не возникнет.
Первый документ будет вести только вперёд, второй — в обе стороны, последний — только назад. Если переставить два адреса в items, изменится порядок изучения, но их собственные URL сохранятся. Переименование content/second.md тоже не повлияет на последовательность, пока не изменён его permalink.
Поле order из прошлого урока остаётся отдельной настройкой. Каталог может показывать статьи по редакционному приоритету или алфавиту, а курс — по необходимым знаниям. Попытка совместить эти два порядка в одном числе часто приводит к неожиданностям: новый материал поднимают в списке, и у старого урока внезапно меняется сосед. Отдельный collections.yaml делает такую правку осмысленным действием.
Порядок хранится в списке, а не в YAML-словаре «номер → адрес». Так не требуется вручную перенумеровывать ключи при вставке нового урока. Сборщик получит индекс каждого элемента непосредственно из положения в списке. Для первого элемента он равен нулю; видимая позиция будет на единицу больше.
Проверка последовательностей до записи файлов
Не стоит молча пропускать адрес, которого нет в сборке. Допустим, второй материал стал черновиком. Если незаметно удалить его из последовательности, первый начнёт вести прямо к третьему, хотя тот может требовать знаний второго. Поэтому ссылки курса будем проверять по публичным документам и считать отсутствие адреса ошибкой.
В build.py перед main добавьте полную функцию:
def build_navigation(documents):
path = ROOT / "data" / "collections.yaml"
if not path.is_file():
raise ValueError("Нужен файл data/collections.yaml")
collections = yaml.safe_load(path.read_text(encoding="utf-8"))
if not isinstance(collections, dict):
raise ValueError("collections.yaml должен содержать словарь")
by_url = {item["permalink"]: item for item in documents}
owners = {}
sequences = []
for name, collection in collections.items():
if not isinstance(name, str) or not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name):
raise ValueError(f"Недопустимый идентификатор последовательности: {name!r}")
if not isinstance(collection, dict) or set(collection) != {"items"}:
raise ValueError(f"{name}: ожидается словарь с единственным полем items")
items = collection["items"]
if not isinstance(items, list) or not items:
raise ValueError(f"{name}: items должен быть непустым списком")
seen = set()
for url in items:
permalink_to_path(url)
if url in seen:
raise ValueError(f"{name}: повтор адреса {url}")
if url not in by_url:
raise ValueError(f"{name}: нет публичной статьи {url}")
if url in owners:
raise ValueError(f"{url}: участие в двух последовательностях")
seen.add(url)
owners[url] = name
sequences.append(items)
navigation = {}
for items in sequences:
total = len(items)
for index, url in enumerate(items):
links = []
if index > 0:
previous = by_url[items[index - 1]]
links.append(
'<a rel="prev" href="' + escape(previous["permalink"], quote=True)
+ '">← ' + escape(previous["title"]) + '</a>'
)
if index + 1 < total:
following = by_url[items[index + 1]]
links.append(
'<a rel="next" href="' + escape(following["permalink"], quote=True)
+ '">' + escape(following["title"]) + ' →</a>'
)
body = f'<p>Урок {index + 1} из {total}</p>'
if links:
body += '<div class="lesson-navigation__links">' + "".join(links) + '</div>'
navigation[url] = (
'<nav class="lesson-navigation" aria-label="Навигация по урокам">'
+ body + '</nav>'
)
return navigation
Используются уже имеющиеся импорты re, yaml и escape. На первом этапе функция проверяет все последовательности, на втором создаёт HTML. Она не изменяет документы, не перемещает файлы и не записывает результат. Возвращается словарь «адрес → блок навигации». Статьи, не включённые ни в один курс, просто не получат такого блока.
Мы запрещаем повтор адреса внутри одной последовательности. Иначе у статьи могли бы появиться две разные позиции и две пары соседей, хотя HTML по этому URL существует только в одном экземпляре. По той же причине учебный вариант не позволяет одной странице участвовать сразу в двух последовательностях. Для такого интерфейса потребовались бы отдельные контексты курса или несколько явно подписанных блоков переходов; текущая модель этого не выражает.
Пустой словарь {} обозначает отсутствие курсов. Пустой items у именованного курса считается ошибкой: имя уже заявлено, но содержание не определено. Даже последовательность из одной статьи допустима. Она покажет «Урок 1 из 1» без ссылок на несуществующих соседей.
Значения rel="prev" и rel="next" описывают отношения между документами. Они полезны для семантики HTML, но сами по себе не меняют порядок и не заменяют видимые ссылки. Значения атрибута рассмотрены в справочнике MDN. Реальную связь у нас задаёт проверенный список items.
Место навигации в общем шаблоне
Замените write_page в build.py полностью. Программа уже принимала второй аргумент navigation; теперь используем его в контексте шаблона:
def write_page(page, navigation=""):
context = {
"title": escape(page["title"], quote=True),
"description": escape(page["description"], quote=True),
"body": page["body"],
"page_navigation": navigation,
}
output = DIST / permalink_to_path(page["permalink"])
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(render_layout(context), encoding="utf-8")
Полный 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 }}">
<link rel="stylesheet" href="/assets/site.css">
</head>
<body>
{{ header }}
<main>
<article><h1>{{ title }}</h1>{{ body }}{{ page_navigation }}</article>
</main>
{{ footer }}
</body>
</html>
Навигация находится после содержания внутри article. Переход появляется в тот момент, когда человек закончил чтение, а не заменяет общий хедер. Каталог и страницы проектов используют тот же шаблон, но получают пустое значение page_navigation, поскольку не входят в учебную последовательность.
Текст названия экранируется при создании ссылки. Весь блок затем передаётся в шаблон как готовый HTML: если экранировать его ещё раз в write_page, на странице появились бы буквальные <nav> и <a>. Это та же граница между обычным текстом и уже сформированной разметкой, которую мы определили для body в уроке о шаблонах.
Добавьте в конец public/assets/site.css:
.lesson-navigation { margin-top: 3rem; padding: 1.25rem; background: var(--surface); }
.lesson-navigation p { margin: 0 0 0.8rem; color: var(--muted); }
.lesson-navigation__links { display: flex; flex-wrap: wrap; gap: 1rem 2rem; }
.lesson-navigation__links a { flex: 1 1 14rem; }
.lesson-navigation__links a[rel="next"] { text-align: right; margin-left: auto; }
Длинные русские названия могут занимать несколько строк. Поэтому ссылки не имеют жёсткой высоты и допускают перенос. На узком экране они расположатся друг под другом, оставаясь обычными ссылками, доступными с клавиатуры. У первой страницы единственный переход вперёд будет выровнен вправо; у последней переход назад останется слева.
Элемент nav обозначает область переходов. Подпись aria-label отличает её от основной навигации в хедере; назначение элемента описано в документации MDN. Здесь подпись относится именно к учебной последовательности, а не ко всем ссылкам сайта.
Подготовка и запись единого результата
Замените main целиком, сохранив остальные функции:
def main():
documents = load_documents()
pages = {document["permalink"]: document for document in documents}
build_catalog(documents, pages)
navigation = build_navigation(documents)
validate_public(pages)
if DIST.exists():
shutil.rmtree(DIST)
DIST.mkdir()
copy_public()
for page in pages.values():
write_page(page, navigation.get(page["permalink"], ""))
print(f"Создано страниц: {len(pages)}")
build_navigation получает именно статьи documents, а не все pages. Поэтому в курс нельзя случайно включить страницу каталога только на основании того, что её маршрут существует в реестре. Подготовка ссылок и проверка ресурсов снова идут до очистки dist. Если в коллекции опечатка, предыдущая локальная сборка останется на месте.
Выполните .venv/bin/python build.py, затем откройте сайт через .venv/bin/python serve.py. Число страниц должно остаться равным семи: переходы добавились в существующие документы. На второй статье ожидается такой смысловой HTML-фрагмент:
<nav class="lesson-navigation" aria-label="Навигация по урокам">
<p>Урок 2 из 3</p>
<div class="lesson-navigation__links">
<a rel="prev" href="/index.html">← Статический сайт из Markdown</a>
<a rel="next" href="/my/legacy/first.php">Сохранённая HTML-страница →</a>
</div>
</nav>
Отступы здесь показаны для чтения; функция записывает более компактную строку. Перейдите назад: у главной должен быть только next, ведущий на вторую статью. Перейдите вперёд дважды: у сохранённого материала должен быть только prev. Проверка средней страницы не обнаруживает ошибки границ, поэтому первую и последнюю нужно рассмотреть отдельно.
Для самостоятельного разбора переставьте вторую и третью строки в items и повторите сборку. У главной изменится сосед, а адреса страниц останутся прежними. После этого верните исходный порядок. Ещё один полезный случай — добавить draft: true второй статье: сборщик должен остановиться на отсутствующей публичной статье, а не автоматически соединить первый и третий материалы. Затем удалите экспериментальный флаг или замените его на draft: false. Перед следующим уроком в сборке снова должны участвовать все три документа, а items должен содержать исходные три адреса в порядке, показанном в начале урока.
Теперь библиотека имеет два независимых способа перехода: каталог помогает выбрать тему, а последовательность ведёт через конкретный пример. Обе возможности работают без запроса к серверной программе. Для поиска по словам понадобится ещё одно представление тех же материалов — индекс, который сборщик подготовит для браузера в следующем уроке.