Каталог из метаданных статей
После переноса собственной HTML-страницы в проекте есть три материала. Их адреса уже заданы: /index.html, /articles/second.html и /my/legacy/first.php. Пока читатель должен знать эти адреса заранее или пользоваться ссылками в хедере. Добавим каталог, который собирается из метаданных и позволяет находить статьи по разделу, теме и связанному проекту.
Такое устройство особенно полезно при расширении библиотеки. В ProfessorWeb новое направление может соседствовать со старым материалом, сохранившим прежний URL. Каталог описывает принадлежность статьи, а не диктует её местоположение. В этом уроке один документ будет участвовать в нескольких списках, но сборщик создаст его содержание только по одному постоянному адресу.
Раздел, тема и проект
В YAML всех трёх исходников content/index.md, content/second.md и content/legacy.md добавьте следующие поля перед закрывающей строкой ---. Существующие title, description, permalink и тексты не меняйте:
section: frontend
topic: content-sites
projects:
- professorweb
Раздел — основное направление. Тема уточняет его, например создание сайтов из контента внутри frontend. Проект связывает материалы о конкретной работе и может объединять статьи из разных направлений. В нашей модели раздел у документа один, тема необязательна, а проектов может быть несколько. Поэтому projects — список даже для одного значения.
Идентификаторы frontend, content-sites и professorweb предназначены для программы и URL. Читателю покажем русские названия. Если впоследствии заменить «Фронтенд» на «Разработка интерфейсов», не придётся менять идентификатор и адрес каталога. Это такое же разделение имени и адреса, как у title и permalink статьи.
При желании добавьте order: 10 первому материалу, order: 20 второй статье и order: 30 сохранённой странице. Это порядок в списках каталога. Он не создаёт связь «предыдущий урок / следующий урок»: учебная последовательность будет отдельным объектом в следующем уроке. Без order документ получит условное значение 1000 и будет отсортирован по названию.
Одна статья может появиться в разделе, теме и проекте одновременно. Все три ссылки ведут на исходный permalink; мы не создаём варианты вроде /catalog/frontend/second.html. Иначе изменение одной статьи потребовало бы синхронной записи нескольких копий, а поисковому роботу пришлось бы разбираться с одинаковым содержанием по разным URL.
Реестр всех создаваемых страниц
До этого сборщик знал только документы из content. Каталог добавляет страницы без отдельных Markdown-исходников. Чтобы контролировать их адреса вместе со статьями, заведём общий словарь pages: ключом служит URL, значением — данные страницы. После появления поиска и карты сайта этот реестр останется единым источником списка страниц.
В build.py перед main добавьте функции из следующих листингов. Импорт escape и функция permalink_to_path уже существуют после уроков о шаблонах и адресах. Сначала добавим запись в реестр:
def add_page(pages, page):
for name in ("title", "description", "permalink"):
if not isinstance(page.get(name), str) or not page[name].strip():
raise ValueError(f"Страница: {name} должен быть непустой строкой")
if not isinstance(page.get("body"), str):
raise ValueError("Страница: body должен быть HTML-строкой")
url = page["permalink"]
target = permalink_to_path(url)
if url in pages:
raise ValueError(f"Маршрут уже занят: {url}")
for existing_url in pages:
existing = permalink_to_path(existing_url)
if target in existing.parents or existing in target.parents:
raise ValueError(f"Файл и папка маршрута конфликтуют: {url}")
pages[url] = page
Обычные документы уже проверены в load_documents. Для порождённых страниц нужна такая же дисциплина: допустимый путь, единственный владелец адреса и отсутствие конфликта файла с папкой. Если в Markdown случайно задан /catalog/index.html, генератор должен сообщить об этом, а не молча перезаписать страницу каталога. В дальнейшем через add_page будут проходить и другие порождённые страницы.
body здесь готовый HTML. write_page по-прежнему добавит общий H1, хедер и футер. Мы не создаём второй набор шаблонов специально для каталога: одинаковая оболочка помогает изменять оформление всей библиотеки одним действием.
Справочники и проверка метаданных
Для маленького учебного сайта справочники можно держать в программе. Добавьте перед функциями каталога следующие определения:
SECTIONS = {"frontend": "Фронтенд"}
TOPICS = {("frontend", "content-sites"): "Сайты из контента"}
PROJECTS = {"professorweb": "ProfessorWeb"}
def validate_catalog(documents):
for document in documents:
url = document["permalink"]
section = document.get("section")
topic = document.get("topic")
projects = document.get("projects", [])
order = document.get("order", 1000)
if not isinstance(section, str) or section not in SECTIONS:
raise ValueError(f"{url}: неизвестный раздел {section!r}")
if topic is not None:
if not isinstance(topic, str) or (section, topic) not in TOPICS:
raise ValueError(f"{url}: неизвестная тема {topic!r}")
if not isinstance(projects, list):
raise ValueError(f"{url}: projects должен быть списком")
if any(not isinstance(item, str) or item not in PROJECTS
for item in projects):
raise ValueError(f"{url}: неизвестный проект")
if len(projects) != len(set(projects)):
raise ValueError(f"{url}: повтор проекта")
if type(order) is not int or order < 0:
raise ValueError(f"{url}: order должен быть целым неотрицательным числом")
def html_link(url, title):
return f'<a href="{escape(url, quote=True)}">{escape(title)}</a>'
def article_list(documents):
ordered = sorted(
documents,
key=lambda item: (
item.get("order", 1000), item["title"].casefold(), item["permalink"]
),
)
if not ordered:
return "<p>В этом списке пока нет статей.</p>"
items = [
"<li>" + html_link(item["permalink"], item["title"])
+ "<p>" + escape(item["description"]) + "</p></li>"
for item in ordered
]
return "<ul>" + "".join(items) + "</ul>"
Значения для TOPICS привязаны к паре раздел–тема. Одного знания content-sites недостаточно: программа должна проверить, что тема разрешена в выбранном разделе. Так опечатка не создаст незаметно новую ветку каталога. Пустая строка не считается отсутствующей темой; если тема не нужна, просто не указывайте поле.
Строгая проверка order исключает логические true и false: в Python они связаны с целыми числами, но в редакторских данных не обозначают позиции. Пустой список проектов допустим; повтор одного проекта обнаруживается как ошибка. Пока проверяем только публичные documents, потому что черновики не участвуют в каталоге. После снятия draft их метаданные тоже должны пройти проверку.
Функция html_link экранирует название как текст и URL как значение атрибута. Сам адрес берётся из уже проверенных маршрутов или из справочника, а не из произвольного пользовательского ввода. article_list использует три ключа сортировки: числовой порядок, название без учёта регистра и адрес. Последний ключ делает результат определённым даже при одинаковых названиях.
В настоящем ProfessorWeb справочники вынесены из движка и охватывают больше направлений. В учебном проекте константы позволяют увидеть механизм без ещё одного формата конфигурации. Расширение каталога потребует согласованного добавления идентификатора и названия; изменение текста одной статьи этого не заменяет.
Формирование страниц каталога
Теперь добавьте полную функцию build_catalog:
def build_catalog(documents, pages):
validate_catalog(documents)
used_sections = sorted({item["section"] for item in documents})
used_topics = sorted({
(item["section"], item["topic"])
for item in documents if item.get("topic") is not None
})
used_projects = sorted({
project for item in documents for project in item.get("projects", [])
})
def add(url, title, description, body):
add_page(pages, {
"permalink": url, "title": title,
"description": description, "body": body,
})
section_links = "".join(
"<li>" + html_link(f"/catalog/{key}/index.html", SECTIONS[key]) + "</li>"
for key in used_sections
)
project_links = "".join(
"<li>" + html_link(f"/projects/{key}/index.html", PROJECTS[key]) + "</li>"
for key in used_projects
)
root_body = "<h2>Разделы</h2>"
root_body += "<ul>" + section_links + "</ul>" if section_links else "<p>Статей пока нет.</p>"
if project_links:
root_body += "<h2>Проекты</h2><ul>" + project_links + "</ul>"
add("/catalog/index.html", "Каталог", "Разделы учебного сайта.", root_body)
back = "<p>" + html_link("/catalog/index.html", "Каталог") + "</p>"
for section in used_sections:
subset = [item for item in documents if item["section"] == section]
topic_links = "".join(
"<li>" + html_link(
f"/catalog/{section}/{topic}/index.html", TOPICS[(section, topic)]
) + "</li>"
for parent, topic in used_topics if parent == section
)
body = back
if topic_links:
body += "<h2>Темы</h2><ul>" + topic_links + "</ul>"
body += "<h2>Статьи раздела</h2>" + article_list(subset)
add(f"/catalog/{section}/index.html", SECTIONS[section],
"Материалы раздела " + SECTIONS[section] + ".", body)
for section, topic in used_topics:
subset = [item for item in documents
if item["section"] == section and item.get("topic") == topic]
body = back + "<p>" + html_link(
f"/catalog/{section}/index.html", SECTIONS[section]
) + "</p>" + article_list(subset)
add(f"/catalog/{section}/{topic}/index.html", TOPICS[(section, topic)],
"Материалы темы " + TOPICS[(section, topic)] + ".", body)
for project in used_projects:
subset = [item for item in documents if project in item.get("projects", [])]
add(f"/projects/{project}/index.html", PROJECTS[project],
"Материалы проекта " + PROJECTS[project] + ".",
back + article_list(subset))
Сначала определяются используемые группы, затем создаются списки ссылок и страницы. В данном наборе данных возникнут ровно четыре новых адреса: корень каталога, раздел frontend, тема content-sites и проект professorweb. Если справочник содержит направление без публичных статей, функция пока не создаёт для него пустую страницу. Это сознательное правило примера: каталог отражает доступные материалы, а не редакционные планы.
Заметьте, что build_catalog только добавляет словари в pages. Она ничего не пишет в dist. Поэтому ошибки рубрик и коллизии адресов обнаруживаются до очистки предыдущего результата. Разделение подготовки и записи также пригодится для построения sitemap: его можно будет получить из того же реестра.
Замените main полностью:
def main():
documents = load_documents()
pages = {document["permalink"]: document for document in documents}
build_catalog(documents, pages)
validate_public(pages)
if DIST.exists():
shutil.rmtree(DIST)
DIST.mkdir()
copy_public()
for page in pages.values():
write_page(page)
print(f"Создано страниц: {len(pages)}")
Функции ресурсов из пятого урока сохраняются. validate_public теперь получает все адреса, включая каталог, поэтому ресурс public/catalog/index.html тоже считается конфликтом. Начальный словарь статей не теряет source, text и прочие поля; новые каталожные страницы содержат только нужные для отображения сведения.
В layouts/header.html внутри уже существующего nav добавьте ссылку:
<a href="/catalog/index.html">Каталог</a>
После выполнения .venv/bin/python build.py ожидается сообщение Создано страниц: 7. Для просмотра используйте .venv/bin/python serve.py, как в предыдущем уроке: этот локальный помощник корректно выдаёт сохранённую страницу с расширением .php. Через общий хедер посетитель сможет открыть каталог, перейти в раздел или проект и добраться до всех трёх статей. Ссылка на сохранённый материал во всех списках должна оставаться /my/legacy/first.php.
Чтобы увидеть связь метаданных с результатом, временно уберите topic только у второй статьи. Она должна исчезнуть из списка темы, но остаться в разделе и проекте. Её собственный адрес и содержание сохранятся. Верните поле перед продолжением серии. Если вместо этого написать topic: content-site, сборщик должен указать неизвестную тему, а не создать похожий адрес с ошибкой.
Каталог решает задачу группировки. Однако удобный порядок изучения не всегда совпадает с алфавитом или числом order: первая статья может объяснять основу, а следующая — развивать её пример. В следующем уроке зададим такую последовательность явно и выведем переходы между её действительными соседями.