Стили, изображения и папка ресурсов
В предыдущем уроке две страницы получили общие шаблоны. Их содержание различается, а хедер и футер собираются из одних файлов. Теперь добавим оформление и изображение так, чтобы они одинаково работали на главной странице и по вложенному адресу /articles/second.html.
В ProfessorWeb общие ресурсы тоже отделены от текстов. Это позволяет менять оформление библиотеки без исправления каждой статьи. В нашем учебном проекте достаточно одного CSS-файла и небольшого SVG: на их примере рассмотрим копирование ресурсов, корневые адреса и причину, по которой страницу с картинками удобнее открывать через локальный HTTP-сервер.
Исходники ресурсов и адреса в браузере
Создайте в папке markdown-site каталог public/assets. Название public означает, что содержимое этой папки предназначено для посетителей. При сборке мы скопируем его в dist без преобразования. Поэтому public/assets/site.css станет dist/assets/site.css, а браузер запросит его по адресу /assets/site.css.
markdown-site/
├── content/
│ ├── index.md
│ └── second.md
├── layouts/
│ ├── page.html
│ ├── header.html
│ └── footer.html
├── public/
│ └── assets/
│ ├── site.css
│ └── site.svg
└── build.py
Обратите внимание на отсутствие public в готовом URL. Это имя исходной папки на компьютере, а корнем HTTP-сайта станет dist. Аналогично имя content не обязано присутствовать в адресе статьи: для неё мы уже задаём permalink.
Начальный слеш в /assets/site.css заставляет браузер считать путь от корня текущего сайта. Если написать assets/site.css без слеша, на странице /articles/second.html получится запрос /articles/assets/site.css. Так появляется типичная ошибка: главная выглядит правильно, а вложенные страницы теряют оформление. Корневой путь решает её для сайта, размещённого в корне домена. При публикации под префиксом вроде /manual/ понадобилось бы отдельно учитывать этот префикс во всех URL; наш учебный пример пока его не использует.
В public не следует класть виртуальное окружение, Markdown, архив исходного сайта или файлы с паролями. Сборщик не выбирает из этой папки только картинки: он копирует всё. Для ресурсов используем отдельную ветку assets, чтобы их имена не конкурировали с адресами материалов.
Небольшое общее оформление
Поместите в public/assets/site.css следующий полный текст:
:root {
color-scheme: dark;
--background: #1b1c24;
--surface: #242630;
--text: #f1f1f5;
--muted: #bec1ce;
--accent: #b798ff;
--green: #49dc75;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--background);
color: var(--text);
font: 1rem/1.75 system-ui, sans-serif;
}
a { color: var(--accent); text-underline-offset: 0.18em; }
a:hover { color: var(--green); }
a:focus-visible { outline: 3px solid var(--green); outline-offset: 4px; }
.site-header, .site-footer, main {
width: min(100% - 2rem, 70rem);
margin-inline: auto;
}
.site-header, .site-footer { padding-block: 1.5rem; }
.site-header { display: flex; flex-wrap: wrap; gap: 1rem 2rem; align-items: center; }
.brand { display: inline-flex; align-items: center; gap: 0.7rem; font-weight: 700; }
.brand img { width: 2.5rem; height: 2.5rem; }
main { flex: 1; padding-block: 2rem 3rem; }
article { max-width: 48rem; }
h1, h2, h3 { line-height: 1.25; text-wrap: balance; }
h1 { font-size: clamp(2rem, 4vw, 3rem); }
h2 { margin-top: 2.2rem; }
.site-footer { color: var(--muted); }
img { max-width: 100%; height: auto; }
pre { overflow-x: auto; padding: 1rem; background: var(--surface); }
code { font-family: ui-monospace, monospace; }
table { border-collapse: collapse; width: 100%; }
th, td { text-align: left; padding: 0.6rem; border-bottom: 1px solid #454854; }
Здесь сохранены знакомые фиолетовый и зелёный акценты ProfessorWeb, но сама разметка остаётся учебной. Переменные в :root позволяют изменить палитру в одном месте. Общий шрифт берётся из системы, поэтому для первого отображения не требуется загрузка отдельного файла шрифта. Это полезный начальный вариант для текстовой библиотеки; выбор фирменного шрифта можно сделать позже.
Сочетание min-height: 100vh, вертикального flex у body и flex: 1 у main оставляет футер у нижней границы короткой страницы. На длинной статье футер окажется после содержания. Он не закреплён поверх текста и не мешает чтению. Ограничение article делает строки короче, а широкая общая оболочка оставляет место для будущей навигации.
В public/assets/site.svg сохраните полный рисунок:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect x="4" y="7" width="56" height="46" rx="8" fill="#9c63ee"/>
<rect x="10" y="21" width="44" height="26" rx="2" fill="#1b1c24"/>
<circle cx="13" cy="14" r="2" fill="#1b1c24"/>
<circle cx="20" cy="14" r="2" fill="#1b1c24"/>
<circle cx="27" cy="14" r="2" fill="#1b1c24"/>
<path d="M17 33 32 27 47 33 32 39Z" fill="#f1f1f5"/>
<path d="M23 37v5c6 4 12 4 18 0v-5" fill="none" stroke="#f1f1f5" stroke-width="3"/>
<path d="m44 37 15 6-6 3 5 6-5 4-5-6-3 6Z" fill="#49dc75" stroke="#1b1c24" stroke-width="2"/>
</svg>
Это самостоятельная простая иллюстрация для примера. SVG хранит геометрию, поэтому маленький знак не требует нескольких растровых копий для разных экранов. В хедере изображение будет декоративным рядом с написанным названием сайта. По этой причине дадим ему пустой alt: название уже доступно как текст, и его не требуется произносить второй раз.
Замените layouts/header.html полностью:
<header class="site-header">
<a class="brand" href="/index.html">
<img src="/assets/site.svg" alt="" width="40" height="40">
<span>Учебный сайт</span>
</a>
<nav aria-label="Основная навигация">
<a href="/articles/second.html">Вторая статья</a>
</nav>
</header>
В layouts/footer.html поместите:
<footer class="site-footer">
Статический сайт из Markdown — учебный проект.
</footer>
Полный 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 }}</article></main>
{{ footer }}
</body>
</html>
Мы изменили оболочку, а не тексты двух материалов. Их заголовки и описания по-прежнему приходят из YAML, тело — из Markdown. Размеры изображения в HTML резервируют место ещё до загрузки SVG. CSS при этом может управлять внешним размером знака, не меняя его исходной геометрии.
Копирование без перезаписи страниц
Само наличие public не делает ресурсы частью готового сайта. Добавим в build.py две функции перед main. Импорт shutil уже есть после предыдущего урока, а ROOT, DIST и permalink_to_path сохраняются:
def validate_public(pages):
public = ROOT / "public"
if public.is_symlink():
raise ValueError("public не должна быть символической ссылкой")
if not public.exists():
return
if not public.is_dir():
raise ValueError("public должна быть папкой")
page_paths = [permalink_to_path(url) for url in pages]
for source in sorted(public.rglob("*")):
if source.is_symlink():
raise ValueError(f"Символическая ссылка в public: {source}")
relative = source.relative_to(public)
if source.is_dir():
if any(relative == target or target in relative.parents
for target in page_paths):
raise ValueError(f"Папка ресурса заняла файл страницы: {relative}")
continue
if not source.is_file():
raise ValueError(f"Ожидался обычный файл: {source}")
for page_path in page_paths:
if (relative == page_path
or relative in page_path.parents
or page_path in relative.parents):
raise ValueError(f"Ресурс конфликтует со страницей: {relative}")
def copy_public():
public = ROOT / "public"
if public.exists():
shutil.copytree(public, DIST, dirs_exist_ok=True)
validate_public получает словарь, ключами которого служат адреса страниц. Она не копирует файлы. Сначала мы выясняем, нет ли в исходниках ресурса с именем будущей HTML-страницы или файла, занявшего место её родительской папки. Например, public/index.html конкурировал бы с главной, а обычный файл public/articles помешал бы создать dist/articles/second.html.
Символические ссылки в этом маленьком проекте запрещаем: иначе копирование могло бы незаметно захватить содержимое другой папки. Это ограничение учебного сборщика, а не общее требование к статическим сайтам. Поведение copytree и параметра dirs_exist_ok описано в документации Python: разрешаем существование уже созданной dist, а вложенные ресурсы копируются рекурсивно.
Замените main целиком; остальные функции не меняйте:
def main():
documents = load_documents()
validate_public({page["permalink"]: page for page in documents})
if DIST.exists():
shutil.rmtree(DIST)
DIST.mkdir()
copy_public()
for page in documents:
write_page(page)
print(f"Создано страниц: {len(documents)}")
Проверка ресурсов происходит до удаления предыдущего результата. Копирование — после создания чистой папки, но до записи страниц. В такой последовательности забытый HTML в public не сможет молча подменить собранную статью. Сборка остаётся простой и пока не является атомарной: ошибка записи после очистки всё ещё может оставить неполную dist. Позже отделим проверенную сборку от действующего сайта при публикации.
Просмотр на двух разных адресах
Выполните сборку и запустите локальный просмотр из папки markdown-site:
.venv/bin/python build.py
.venv/bin/python -m http.server 8000 --bind 127.0.0.1 --directory dist
Ожидается, что /index.html и /articles/second.html покажут один знак, одну палитру и одинаковый футер. Отдельно откройте /assets/site.svg: это позволит отличить отсутствие самого файла от ошибки в HTML-ссылке. При просмотре через file:// начальный слеш относится к корню файловой системы, поэтому такой способ не подходит для наших корневых URL.
Изменение --accent в исходном CSS должно проявиться на обеих страницах после пересборки и обновления браузера. Если старая окраска сохраняется, сначала убедитесь, что изменён public/assets/site.css, а не копия в dist; затем учитывайте кеш браузера. На будущей публикации версионирование ресурсов поможет обновлять кеш предсказуемо, но для небольшого локального примера достаточно повторного запроса.
После этого шага шаблоны отвечают за общую структуру, Markdown — за содержание, public — за ресурсы. Такое разделение потребуется в следующем уроке: мы добавим сохранённый учебный HTML-фрагмент, который должен использовать новое оформление, не теряя свою таблицу, код и адрес.