Несколько страниц и постоянные URL
Теперь название и описание нашей страницы хранятся в YAML. Однако сборщик по-прежнему читает только content/index.md и всегда пишет dist/index.html. Чтобы добавить статью, можно повторить этот фрагмент программы с другими именами. Но тогда список материалов придётся поддерживать одновременно в исходниках и в Python.
Рассмотрим другой вариант: сборщик находит все Markdown-файлы, а каждый документ сам указывает свой публичный адрес. Мы создадим вторую статью, оставив первую на прежнем месте. Затем перенесём исходник между папками и увидим, почему расположение текста на диске не должно определять URL опубликованного материала.
Адрес страницы и имя исходника
При обновлении ProfessorWeb основной задачей было сохранить прежние адреса. Шаблон и формат хранения менялись, а внешние ссылки продолжали указывать на те же страницы. Этот принцип применим и к небольшому новому сайту: лучше заранее отделить организацию исходников от публичной структуры.
Добавим в метаданные content/index.md одно свойство. Всё начало файла должно выглядеть так:
---
title: Статический сайт из Markdown
description: Учебный сайт из Markdown.
permalink: /index.html
---
Тело статьи не меняется. permalink обозначает постоянный адрес документа относительно корня сайта. В нашем примере ему соответствует файл dist/index.html. Другая статья может лежать в любой подпапке content, но её выходной путь будет выбран из метаданных.
Создайте content/second.md со следующим полным содержимым:
---
title: Вторая статья
description: Ещё одна страница учебного сайта.
permalink: /articles/second.html
---
## Отдельный исходник
У этой статьи свой **постоянный адрес**.
[Вернуться к первому материалу](/index.html).
Получилась связь между двумя документами. Ссылка начинается с /, поэтому разрешается относительно корня сайта, а не папки /articles/. При локальном HTTP-просмотре это означает тот же сервер и его корень. Открытие файла непосредственно через file:// не воспроизводит такие переходы: браузер будет искать путь в файловой системе компьютера.
Выбор /articles/second.html пока произволен. Можно было бы сохранить старый адрес вида /my/legacy/first.php: содержимое выходного файла всё равно было бы HTML. Однако выдача HTML по расширению .php зависит от конфигурации сервера. Мы разберём эту особенность при переносе и размещении сайта; само создание файла не проверяет HTTP-поведение хостинга.
Преобразование URL в путь
Нельзя просто соединить DIST со строкой из YAML. Если справа находится абсолютный путь, операция объединения путей может отбросить левую часть. Компоненты .. также способны вывести запись за пределы папки результата. Сборщик должен принимать ограниченный формат адресов и превращать его в относительный путь.
Для учебного проекта договоримся об адресах с явным именем файла. Они начинаются с / и заканчиваются на .html или .php. Для будущего каталога используем /catalog/index.html, а не папочную форму /catalog/. Это позволяет одинаково преобразовывать статьи и порождённые страницы, не добавляя скрытых правил.
Замените блок импортов в build.py следующим; определения ROOT и DIST ниже него сохраняются:
from html import escape
from pathlib import Path
import shutil
from urllib.parse import urlsplit
import markdown
import yaml
После read_document добавьте новую функцию:
def permalink_to_path(url):
if not isinstance(url, str) or not url.startswith("/"):
raise ValueError("permalink должен начинаться с /")
if any(char.isspace() or ord(char) < 32 or ord(char) == 127 for char in url):
raise ValueError(f"Пробел или управляющий символ в permalink: {url!r}")
if any(char in url for char in ("%", "\\", "?", "#")):
raise ValueError(f"Недопустимый символ в permalink: {url}")
parsed = urlsplit(url)
if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment:
raise ValueError(f"Нужен путь без домена и параметров: {url}")
parts = url[1:].split("/")
if any(part in ("", ".", "..") for part in parts):
raise ValueError(f"Недопустимый компонент permalink: {url}")
if not parts[-1].endswith((".html", ".php")):
raise ValueError(f"Нужно окончание .html или .php: {url}")
return Path(*parts)
urlsplit разделяет URL на компоненты, но не заменяет проверку допустимости адреса. Это различие прямо отмечено в документации Python. Поэтому программа отдельно запрещает пробелы, управляющие символы, параметры и фрагмент. Знак % отклоняется, чтобы в этом учебном формате не появлялись дополнительные способы кодирования компонентов пути. Правило намеренно уже общего стандарта URL.
Список parts строится после удаления первого /. В нём нет пустых компонентов, точки и двух точек; значит, Path(*parts) описывает относительный путь внутри результата. Например, для /articles/second.html получается articles/second.html. Обратный слеш запрещён отдельно, чтобы смысл исходника не менялся при переносе между системами.
| Значение | Что делает сборщик |
|---|---|
/index.html |
Возвращает путь index.html |
/articles/second.html |
Возвращает путь articles/second.html |
/my/legacy/first.php |
Допускает имя с расширением .php |
/../outside.html |
Отклоняет выход через .. |
//other.example/page.html |
Отклоняет URL с хостом |
/articles/second.html?view=1 |
Отклоняет параметры |
/articles//second.html |
Отклоняет пустой компонент |
Настоящий старый сайт может содержать более сложные адреса. Перед миграцией нужно изучить их реестр и расширять формат осознанно. Этот урок не предлагает автоматически переименовать все адреса, которые не укладываются в наш небольшой пример.
Чтение всех документов и черновики
Теперь дополним read_document из прошлого урока. В цикле проверки обязательных свойств замените одну строку:
for name in ("title", "description", "permalink"):
Сразу после этого цикла, перед строкой text = "".join(lines[end + 1:]), вставьте проверку флага:
draft = metadata.get("draft", False)
if type(draft) is not bool:
raise ValueError(f"{path.name}: draft должен быть true или false без кавычек")
Возвращаемый словарь замените полностью:
return {
**metadata,
"source": path,
"text": text,
"body": render_markdown(text),
"draft": draft,
}
В этом учебном сборщике отсутствие draft означает false. Для рабочего ProfessorWeb принят более осторожный порядок создания новых статей: они появляются как черновики. Эти правила относятся к двум разным проектам. Здесь мы явно запишем draft: true, когда захотим скрыть материал.
Флаг должен быть логическим значением YAML. Строка draft: "false" не принимается: непустая строка в Python могла бы случайно считаться истинным значением. Проверка точного типа устраняет такую неоднозначность. Текст черновика всё равно разбирается, поэтому ошибки его обязательных метаданных не остаются незамеченными.
После permalink_to_path добавьте load_documents:
def load_documents():
documents = []
routes = {}
output_paths = set()
for path in sorted((ROOT / "content").rglob("*.md")):
document = read_document(path)
url = document["permalink"]
relative = permalink_to_path(url)
if url in routes:
raise ValueError(f"Повтор permalink {url}: {routes[url]} и {path}")
if any(parent in output_paths for parent in relative.parents):
raise ValueError(f"Папка маршрута совпадает с файлом: {url}")
if any(relative in existing.parents for existing in output_paths):
raise ValueError(f"Файл маршрута совпадает с папкой: {url}")
routes[url] = path
output_paths.add(relative)
if not document["draft"]:
documents.append(document)
return documents
rglob находит Markdown и во вложенных папках, а sorted делает порядок прохода стабильным. Словарь routes хранит занятые адреса и имя исходника, которому каждый адрес принадлежит. Если два материала задают один permalink, сборка останавливается; последний файл не перезаписывает первый.
Множество output_paths проверяет ещё одну коллизию. Адрес /chapter.html требует файл с этим именем, а /chapter.html/part.html требует папку с тем же именем. Оба одновременно существовать не могут. Проверяем совпадение в обе стороны, чтобы результат не зависел от порядка чтения исходников.
Обратите внимание: проверка адресов выполняется и для черновиков. Их адреса тоже заняты. Иначе ошибка могла бы обнаружиться только в день публикации, когда draft изменится на false. В возвращаемый список, напротив, входят только публичные документы. Именно этот список позже будет использоваться для каталога, поиска и последовательности уроков.
Запись страниц и удаление старого результата
Функция write_page сохраняет прежнюю HTML-оболочку. Замените в ней только последнюю строку записи файла следующими тремя:
output = DIST / permalink_to_path(page["permalink"])
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(html, encoding="utf-8")
Теперь main замените целиком:
def main():
documents = load_documents()
if DIST.exists():
shutil.rmtree(DIST)
DIST.mkdir()
for document in documents:
write_page(document)
print(f"Создано страниц: {len(documents)}")
Обращение к main в конце файла и render_markdown сохраняются. После запуска .venv/bin/python build.py с двумя исходниками ожидается сообщение Создано страниц: 2 и такая структура:
dist/
├── index.html
└── articles/
└── second.html
Почему пересоздаётся dist? Если удалить исходник или сделать его черновиком, простая перезапись оставшихся страниц не уберёт прежний HTML. Посетителю продолжал бы выдаваться материал, который автор считает снятым с публикации. Чистый результат отражает именно текущий список публичных документов.
Удаляется только DIST, вычисленный как ROOT / "dist" отдельного учебного проекта. Не подставляйте сюда папку исходников или корень действующего сайта. В dist не следует хранить единственные копии изображений и другие редактируемые файлы. Для таких ресурсов в следующей части серии появится отдельная исходная папка.
Чтение и проверка документов происходят до удаления старого результата. При неправильном permalink прежняя сборка останется на месте. Однако ошибка во время последующей записи может оставить неполный новый результат. Для серьёзного выпуска нужна сборка в отдельную временную папку и переключение после проверки; в этой серии сначала разберём сам механизм страниц, а процедуру публикации вынесем в последний урок.
Перенос исходника без смены URL
Переместите content/second.md в content/notes/second.md, создав папку notes. Метаданные не меняйте. После повторной сборки ожидается всё тот же dist/articles/second.html. Сборщик обнаружит исходник рекурсивно, а место записи возьмёт из permalink. Таким образом можно упорядочить рабочие материалы, не затрагивая внешние ссылки.
Для другого опыта добавьте второй статье draft: true. В результате должна остаться одна страница, а dist/articles/second.html — исчезнуть. Временно задайте ей permalink: /index.html: даже у черновика возникнет конфликт с первым документом. Значит, скрытие материала не отменяет проверку его будущего адреса.
Наконец, попробуйте permalink: /../outside.html. Ожидаемая последняя строка сообщения об ошибке:
ValueError: Недопустимый компонент permalink: /../outside.html
Перед следующим уроком восстановите исходное состояние: верните файл в content/second.md, задайте permalink: /articles/second.html и удалите draft: true либо замените его на false. Обе страницы снова должны входить в список публичных документов. Теперь добавление и перемещение исходников не требует правок цикла сборки, а дальнейшая работа с оформлением сможет опираться на уже закреплённые адреса.