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

Каталог из метаданных статей

После переноса собственной 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: первая статья может объяснять основу, а следующая — развивать её пример. В следующем уроке зададим такую последовательность явно и выведем переходы между её действительными соседями.