Переводы Markdown и единый ID статьи
До сих пор мы работали с моделью языка и подписями. Теперь превратим «Параллель» в маленькую статическую библиотеку: русский и английский Markdown будут собираться в отдельные HTML-файлы. Связь переводов задаёт реестр, поэтому одинаковые имена файлов и похожие заголовки не понадобятся для угадывания соответствий.
Для учебного сборщика используем Python 3.11 или новее и Python-Markdown 3.10.2. Версия пакета указана в реестре PyPI; зависимости при подготовке уроков не устанавливались. В архиве находится полный итоговый сборщик, а ниже рассматриваем его основную операцию. Это самостоятельный проект, который не заменяет сборку действующего ProfessorWeb.
Исходники по языкам
Содержание первой статьи разместим в content/ru/stable-urls.md и content/en/stable-urls.md. Первый файл начинается так:
Постоянный адрес связывает статью с закладками и внешними ссылками.
## Адрес и название
Название можно уточнить, сохранив уже опубликованный URL.
Английский файл содержит настоящий перевод этого содержания, а не русское тело с английской подписью меню. Заголовок страницы берётся из реестра. Поэтому H1 в теле не повторяем. Подзаголовки и примеры принадлежат конкретной языковой версии, но основной учебный смысл должен оставаться согласованным.
Раздельные файлы упрощают редакционную работу: переводчик видит цельный документ, может менять порядок предложений и не боится смешать текст двух языков в одном условном шаблоне. Цена такого решения — необходимость следить за обновлениями оригинала. Эту задачу позднее решим через номера ревизий.
Не называйте язык исключительно по папке исходника. Реестр является источником связи: если английский файл переместили, его язык остаётся en. При сборке путь проверяется относительно корня проекта. Абсолютные пути и выход из каталога через .. не входят в договор.
Преобразование одной версии
Сохраните зависимость в полном файле requirements.txt:
Markdown==3.10.2
Следующая функция относится к build.py. Переменная ROOT в полном сборщике равна каталогу проекта, а output указывает на каталог текущей сборки. Здесь показана основная операция без поздних метаданных и переключателя языка:
from html import escape
from pathlib import Path
import markdown
ROOT = Path(__file__).resolve().parent
def render_variant(article, language, variant, output):
source = (ROOT / variant["file"]).resolve()
if not source.is_relative_to(ROOT / "content"):
raise ValueError("Content file leaves its directory")
body = markdown.markdown(source.read_text(encoding="utf-8"),
extensions=["fenced_code", "tables"])
title = escape(variant["title"])
page = f'''<!doctype html>
<html lang="{escape(language, quote=True)}">
<head><meta charset="utf-8"><title>{title}</title></head>
<body><main><h1>{title}</h1>{body}</main></body></html>'''
target = output / variant["url"].lstrip("/")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(page, encoding="utf-8")
Функция экранирует название, потому что оно вставляется как текст шаблона. Тело проходит Markdown-рендерер и становится HTML. В этой библиотеке исходники принадлежат доверенным редакторам: Python-Markdown сам по себе не является средством очистки произвольного пользовательского HTML. Для открытой формы публикации понадобится отдельная политика допустимой разметки и её очистка.
Проверка пути URL выполняется до вызова функции. Итоговый сборщик допускает только относительные к домену пути с расширением .html, отклоняет .., повторяющиеся адреса и неизвестные языки. Не переносите укороченную функцию в приложение с недоверенными входами, убрав эти условия из окружения.
Обходим только опубликованные версии
Основной цикл не ищет файлы по маске *.md. Он идёт по реестру и выбирает версии со статусом published. Такой порядок делает отсутствие перевода явным, а случайно оставленный черновик не становится публичным только потому, что находится рядом с готовыми файлами.
for article in articles:
for language, variant in published_variants(article).items():
render_variant(article, language, variant, output)
Для первой статьи ожидаются два файла: out/articles/stable-urls.html и out/en/articles/stable-urls.html. У второй, reading-list, английский перевод пока черновой, поэтому собирается только русский адрес. Число файлов определяется состоянием реестра, а не требованием одинакового числа страниц каждого языка.
Если статус объявлен опубликованным, но файл отсутствует, сборка должна прерваться с понятной ошибкой. Молчаливый пропуск оставит страницу в переключателе и карте сайта без содержимого. По той же причине нельзя незаметно подставлять русский файл на английский URL при ошибке пути.
Одинаковый ID не требует одинакового slug
Английская версия может использовать другой slug. Допустим, редактор выбрал /en/articles/permanent-addresses.html. Связь останется корректной, поскольку её задаёт stable-urls в реестре. Однако менять уже опубликованный английский URL ради нового варианта названия не следует. Внешние ссылки появляются у переводов точно так же, как у оригинала.
Путь исходника можно изменить без внешнего перенаправления. Публичный URL требует отдельного решения и, если переезд необходим, правил перенаправления на хостинге. Наш статический сборщик не превращает запись в JSON в HTTP-ответ 301. Текстовая страница с ссылкой и серверный redirect — разные механизмы, которые нельзя выдавать друг за друга.
Для изображений удобно хранить общий ресурс один раз и переводить его подпись в каждом Markdown-файле. Если текст находится внутри самой иллюстрации, может потребоваться отдельный файл на язык. Путь к картинке не является идентификатором перевода статьи: не связывайте готовность всех материалов со случайным совпадением названий ресурсов.
Ссылки внутри переводимого материала
Тело Markdown тоже содержит адреса. Если русский текст ссылается на reading-list, а английский перевод повторяет тот же href, читатель попадёт на русский материал. Это может быть осознанным решением при отсутствии перевода, но его нужно обозначить в подписи. Похожее имя английского файла не является доказательством существования английского URL.
Для большой библиотеки можно хранить внутренние ссылки через ID и разрешать их сборщиком по языку текущей страницы. Тогда политика отсутствующего перевода применяется централизованно: ссылка на оригинал с пометкой или отсутствие дополнительного перехода. В нашем маленьком проекте адреса записаны явно; мы не добавляем скрытый синтаксис ссылок, который читатель не сможет воспроизвести обычным Markdown.
Та же проблема возникает с якорями подзаголовков. Перевод заголовка может изменить автоматически сформированный фрагмент адреса. Если внешний материал ссылается на конкретную часть статьи, полезно закреплять устойчивый якорь независимо от языка. При переносе контента необходимо заранее выяснить, какие фрагменты уже используются, а не проверять только URL до знака решётки.
Подписи изображений и альтернативный текст переводятся вместе с объяснением. Общий файл схемы может оставаться один, но содержание изображения должно быть понятно каждому читателю. Если в схеме много русского текста, английская версия статьи с той же картинкой ещё не является полностью завершённым переводом.
Теперь у «Параллели» есть явный набор самостоятельных страниц. В следующем уроке построим переключатель языка из того же реестра: он будет вести к переводу текущей статьи и честно показывать отсутствие готовой версии, сохраняя возможность читать оригинал.