CI: деплой через ssh и git pull, README со стилистикой и заметками
deploy / deploy (push) Successful in 9s

- .gitea/workflows/deploy.yml: при пуше в main заходит на сервер по ssh и делает git pull
- deploy/: конфиг nginx (root /opt/vizitka-site/web) и скрипт разовой настройки сервера
- tools/: генераторы страниц архитектуры и проектов (раньше лежали вне репозитория)
- README: стилистика сайта, устройство, деплой, заметки для следующих сессий

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 16:48:04 +05:00
co-authored by Claude Sonnet 5.5
parent 152f1d60b5
commit 8de0a05848
6 changed files with 689 additions and 53 deletions
+209
View File
@@ -0,0 +1,209 @@
"""Генератор страниц раздела «Архитектура»: список /arch/ и страницы проектов /arch/<проект>/.
Запуск из корня репозитория: python3 tools/gen_arch.py
Содержание проектов (тексты, картинки, подписи) лежит в списке CASES ниже."""
import os, html
W = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'web') + '/'
E = html.escape
def head(title, desc, og, depth=1):
return f'''<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>{E(title)}: Аман Ибраш</title>
<meta name="description" content="{E(desc)}">
<meta name="theme-color" content="#f4f6fb">
<meta property="og:type" content="website">
<meta property="og:locale" content="ru_RU">
<meta property="og:title" content="{E(title)}: Аман Ибраш">
<meta property="og:description" content="{E(desc)}">
<meta property="og:image" content="https://example.com/og/{og}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{E(title)}: Аман Ибраш">
<meta name="twitter:description" content="{E(desc)}">
<meta name="twitter:image" content="https://example.com/og/{og}">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="preload" href="/fonts/onest-cyrillic-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/css/style.css">
<script src="/js/boot.js"></script>
</head>
<body class="doc has-nav">
<div class="blob blob--a" aria-hidden="true"></div>
<div class="blob blob--b" aria-hidden="true"></div>
<nav class="topnav" id="topnav" aria-label="Страницы"></nav>
'''
TAIL = '''
<script src="/js/nav.js" defer></script>
<script src="/js/count.js" defer></script>
</body>
</html>
'''
# ---------- данные проектов ----------
CASES = [
dict(slug='quiet-courtyard', title='Тихий двор', sub='Дом в Конаеве вокруг внутреннего двора',
year='2026', place='Конаев', kind='жилой дом', cover='house-pond', og='arch-quiet-courtyard.jpg',
card='Бетон, дерево и двор, в который приходят небо и свет.',
desc='Тихий двор: одноэтажный бетонный дом в Конаеве вокруг внутреннего двора. Проект в диалоге с архитектурой Тадао Андо.',
intro='Дом, который отвернулся от улицы и повернулся к небу.',
stats=[('1','этаж',''),('32','м² внутренний двор',''),('41','м² гостиная-кухня',''),('3','спальни','')],
sections=[
('Улица остаётся снаружи', 'Со стороны улицы это глухая бетонная стена с одной узкой щелью света. Она прячет вход от чужих глаз: чтобы попасть в дом, нужно пройти вдоль сада и только потом повернуть к двери. Рядом под бетонным навесом стоит машина, а сам дом остаётся спокойным и закрытым.'),
('Всё собрано вокруг двора', 'Внутри всё держится на дворе площадью 32 м². Утреннее солнце заходит в него, галерея обходит двор по кругу, а комнаты открываются на гравий, воду и тонкие деревья. Над центром плана стоит зенитный фонарь: он приносит верхний свет в самую глубину дома.'),
('Бетон и тёплое дерево', 'Главный материал один: бетон с отпечатками опалубки. Рядом с ним тёплое дерево дверей и потолочных обшивок, чёрный металл рам и зелень. Проект сделан в диалоге с архитектурой Тадао Андо: меньше деталей, больше света, тишины и тени.')],
gallery=[('house-street','Фасад с улицы: глухая стена и въезд под навес','',1200,896),
('house-courtyard','Двор: стекло, дерево и гравий','',1200,896),
('house-pond','Фасад в сад: бетон, вода и зелень','',1200,896),
('house-gallery','Галерея вокруг двора','',1200,896),
('house-corridor','Коридор вдоль двора','',1200,896),
('house-entry','Вход: свет и тени на бетоне','',1200,896)],
drawings=[('house-concept','Путь и свет: вход с улицы, щель света, путь вдоль сада, зенитный фонарь и утреннее солнце во двор','portrait',1070,1200),
('house-plan','План первого этажа с зонированием','portrait',1040,1440),
('house-axo','Аксонометрия со срезом','wide',1400,1088)]),
dict(slug='exhibition-pavilion', title='Выставочный павильон', sub='Галерея в парке',
year='2026', place='Алматы', kind='общественное здание', cover='pav-2', og='arch-exhibition-pavilion.jpg',
card='Стеклянная витрина среди деревьев и терраса для скульптур.',
desc='Выставочный павильон в Алматы: двухэтажная галерея в парке со стеклянным первым этажом и террасой для скульптур.',
intro='Витрина, которую видно с аллеи ещё до входа.',
stats=[('2','этажа',''),('26','м длина здания',''),('2','галереи',''),('1','терраса для скульптур','')],
sections=[
('Первый этаж как витрина', 'Павильон стоит в парке и работает как витрина: первый этаж почти целиком остеклён, поэтому экспозицию видно с аллеи ещё до входа. Входишь, и сразу попадаешь в холл, из которого открываются обе галереи.'),
('Спокойный план', 'Из холла гости идут в две галереи, а лестница ведёт на второй уровень с открытой террасой для скульптур. Служебные помещения собраны в одном крыле, поэтому залы остаются свободными и чистыми для выставок.'),
('Три материала', 'Фасад собран из трёх материалов: стеклянная витрина, плоскости бетона и тёплые деревянные ламели. Они держат один спокойный ритм и не спорят с парком вокруг.')],
gallery=[('pav-2','Главный фасад','',1600,896),('pav-1','Вход и холл','',1600,893),('pav-3','Вид сверху на парк','',1600,893)],
drawings=[('pav-plan','План первого этажа: две галереи и холл','wide',1600,844)]),
dict(slug='loft-complex', title='Жилой комплекс «Лофт»', sub='Три корпуса вокруг двора без машин',
year='2026', place='Алматы', kind='жилой комплекс', cover='loft-0', og='arch-loft-complex.jpg',
card='Красный кирпич и общий двор, свободный от машин.',
desc='Жилой комплекс «Лофт» в Алматы: три корпуса из красного кирпича вокруг общего двора без машин.',
intro='Красный кирпич, арки на первом этаже и двор, где можно гулять.',
stats=[('3','корпуса',''),('2,11','га участок',''),('7','этажей, корпус 1',''),('1','подземный паркинг','')],
sections=[
('Двор без машин', 'Комплекс стоит на участке 2,11 га в Турксибском районе Алматы. Три корпуса образуют общий двор, и машин в нём нет: парковка спрятана под землёй, а на поверхности остались площадки и озеленение.'),
('Кирпич и арки', 'На первом этаже коммерция за арочными стальными витражами. Выше идут жилые этажи с окнами в стальных рамах. Фасад корпуса 1 выполнен из красного лицевого кирпича, а стальной пояс отделяет коммерческий этаж от квартир.')],
gallery=[('loft-0','Общий вид комплекса','',1600,1198)],
drawings=[('loft-facade','Боковые фасады корпуса 1','wide',1600,877)]),
]
import subprocess
def dims(img):
o = subprocess.run(['magick', 'identify', '-format', '%w %h', W + f'img/arch/{img}.webp'], capture_output=True, text=True).stdout.split()
return int(o[0]), int(o[1])
def srcset(img):
w, _ = dims(img)
parts = []
for suf, ww in (('-sm', 640), ('-md', 960)):
if os.path.exists(W + f'img/arch/{img}{suf}.webp') and ww < w: parts.append(f'/img/arch/{img}{suf}.webp {ww}w')
parts.append(f'/img/arch/{img}.webp {w}w')
return ', '.join(parts)
def fig(img, cap, kind, w_unused, h_unused, lead=False, sizes='(min-width: 62rem) 62rem, calc(100vw - 2.2rem)'):
w, h = dims(img)
cls = 'shot' + (f' shot--{kind}' if kind else '') + (' shot--lead' if lead else '')
lz = ' fetchpriority="high"' if lead else ' loading="lazy" decoding="async" fetchpriority="low"'
return f''' <figure class="{cls}">
<a href="/img/arch/{img}.webp" target="_blank" rel="noopener" aria-label="Открыть крупно: {E(cap)}"><img src="/img/arch/{img}-md.webp" srcset="{srcset(img)}" sizes="{sizes}" width="{w}" height="{h}" alt="{E(cap)}"{lz}></a>
<figcaption>{E(cap)}</figcaption>
</figure>
'''
# ---------- страницы проектов ----------
for i, c in enumerate(CASES):
nxt = CASES[(i + 1) % len(CASES)]
cover = next(g for g in c['gallery'] if g[0] == c['cover'])
rest = [g for g in c['gallery'] if g[0] != c['cover']]
meta = ''.join(f'<li>{E(x)}</li>' for x in (c['year'], c['place'], c['kind']))
stats = ''.join(f'<li class="stat"><b data-count="{n.replace(",", ".")}">{n}</b><span>{E(l)}</span></li>' for n, l, _ in c['stats'])
secs = ''.join(f'<h2>{E(t)}</h2><p>{E(p)}</p>' for t, p in c['sections'])
gal = ''.join(fig(g[0], g[1], g[2], g[3], g[4]) for g in rest)
dr = ''.join(fig(g[0], g[1], g[2], g[3], g[4]) for g in c['drawings'])
h = head(c['title'], c['desc'], c['og'])
h += f'''
<main class="page page--wide">
<p class="back"><a href="/arch/">← Архитектура</a></p>
<header class="case-head">
<ul class="meta">{meta}</ul>
<h1>{E(c['title'])}</h1>
<p class="lead">{E(c['sub'])}</p>
</header>
<div class="gallery gallery--one">
{fig(cover[0], cover[1], 'cover', cover[3], cover[4], lead=True)} </div>
<ul class="case-stats">{stats}</ul>
<section class="case-body">
<p class="intro">{E(c['intro'])}</p>
{secs}
</section>
'''
if rest:
h += f'''
<section class="case-block" aria-label="Визуализации">
<h2 class="block-title">Кадры</h2>
<div class="gallery">
{gal} </div>
</section>
'''
h += f'''
<section class="case-block" aria-label="Чертежи">
<h2 class="block-title">Чертежи и схемы</h2>
<div class="gallery">
{dr} </div>
</section>
<a class="next-case" href="/arch/{nxt['slug']}/">
<span class="muted-w">Следующий проект</span>
<span class="next-title">{E(nxt['title'])}</span>
<span class="next-arrow" aria-hidden="true">→</span>
</a>
</main>
''' + TAIL
os.makedirs(W + f"arch/{c['slug']}", exist_ok=True)
open(W + f"arch/{c['slug']}/index.html", 'w').write(h)
# ---------- список проектов ----------
cards = ''
for i, c in enumerate(CASES):
lead = ' work--lead' if i == 0 else ''
cards += f''' <a class="work{lead}" href="/arch/{c['slug']}/">
<div class="pic"><img src="/img/arch/{c['cover']}-md.webp" srcset="{srcset(c['cover'])}" sizes="(min-width: 62rem) 50vw, calc(100vw - 2.2rem)" width="{dims(c['cover'])[0]}" height="{dims(c['cover'])[1]}" alt="{E(c['title'])}"{' fetchpriority="high"' if i == 0 else ' loading="lazy" decoding="async" fetchpriority="low"'}></div>
<div class="info">
<ul class="meta"><li>{c['year']}</li><li>{E(c['place'])}</li></ul>
<h2>{E(c['title'])}</h2>
<p>{E(c['card'])}</p>
<span class="more">Смотреть проект <span aria-hidden="true">→</span></span>
</div>
</a>
'''
idx = head('Архитектура', 'Учусь архитектуре в KazGASA, Алматы. Мои лучшие проекты: Тихий двор, выставочный павильон и жилой комплекс.', 'arch.jpg')
idx += f'''
<main class="page page--wide">
<header class="page-intro">
<p class="hello">Архитектура</p>
<h1>Рисую здания</h1>
<p class="lead">Я студент KazGASA в Алматы. Здесь проекты, которыми горжусь больше всего. Выбери любой и загляни внутрь.</p>
</header>
<section class="works" aria-label="Проекты">
{cards} </section>
<aside class="soon">
<p class="hello">Дальше</p>
<p>Здесь будет больше: учёба, эскизы и то, что я вижу и фотографирую. Загляни позже.</p>
</aside>
</main>
''' + TAIL
open(W + 'arch/index.html', 'w').write(idx)
# sitemap: дописываем недостающие адреса, чужие не трогаем
sm_path = W + 'sitemap.xml'
sm = open(sm_path).read() if os.path.exists(sm_path) else '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n</urlset>\n'
for u in ['/', '/arch/'] + [f"/arch/{c['slug']}/" for c in CASES]:
if f'<loc>https://example.com{u}</loc>' not in sm:
sm = sm.replace('</urlset>', f' <url><loc>https://example.com{u}</loc></url>\n</urlset>')
open(sm_path, 'w').write(sm)
print('arch pages:', [c['slug'] for c in CASES])
+175
View File
@@ -0,0 +1,175 @@
"""Генератор страниц раздела «Проекты»: список /projects/, игра /projects/amans-world/ и хоумлаб /projects/homelab/.
Запуск из корня репозитория: python3 tools/gen_projects.py (сначала должен существовать tools/gen_arch.py: из него берётся общая шапка)"""
import os, html, subprocess, re
W = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'web') + '/'
E = html.escape
src = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'gen_arch.py')).read()
# берём head() и TAIL из генератора архитектуры, чтобы шапка была одинаковой
ns = {'os': os, 'html': html, 'W': W, 'E': E}
exec(src[src.index('def head('):src.index('# ---------- данные проектов ----------')], ns)
head, TAIL = ns['head'], ns['TAIL']
def dims(img):
o = subprocess.run(['magick', 'identify', '-format', '%w %h', W + f'img/proj/{img}.webp'], capture_output=True, text=True).stdout.split()
return int(o[0]), int(o[1])
def srcset(img):
w, _ = dims(img); parts = []
for suf, ww in (('-sm', 640), ('-md', 960)):
if os.path.exists(W + f'img/proj/{img}{suf}.webp') and ww < w: parts.append(f'/img/proj/{img}{suf}.webp {ww}w')
parts.append(f'/img/proj/{img}.webp {w}w'); return ', '.join(parts)
def fig(img, cap, kind='', lead=False):
w, h = dims(img)
cls = 'shot' + (f' shot--{kind}' if kind else '') + (' shot--lead' if lead else '')
lz = ' fetchpriority="high"' if lead else ' loading="lazy" decoding="async" fetchpriority="low"'
return f''' <figure class="{cls}">
<a href="/img/proj/{img}.webp" target="_blank" rel="noopener" aria-label="Открыть крупно: {E(cap)}"><img src="/img/proj/{img}-md.webp" srcset="{srcset(img)}" sizes="(min-width: 62rem) 62rem, calc(100vw - 2.2rem)" width="{w}" height="{h}" alt="{E(cap)}"{lz}></a>
<figcaption>{E(cap)}</figcaption>
</figure>
'''
GAME = 'https://grid.ibrash.duckdns.org/'
# ---------- страница игры ----------
title = "Aman's World"
desc = "Aman's World: 3D-игра с паркуром и гонками, которая запускается прямо в браузере. Неоновый хаб, гонки Sector 3 Run и Ascend Only."
stats = [('5','порталов в хабе'),('8','чекпоинтов, Sector 3 Run'),('13','чекпоинтов, Ascend Only'),('40','МБ при первом запуске')]
secs = [
('Неоновый хаб', 'Ты начинаешь в хабе: зал с неоновыми колоннами, вращающейся скульптурой-атомом и порталами на карты. Над гоночными порталами висят таблицы рекордов. Перед входом вводишь ник до 16 символов.'),
('Паркур', 'Движение собрано под паркур: бег, прыжок, двойной прыжок, рывок, скольжение, бег по стенам, прыжок от стены и подтягивание на уступы. На трассах лежат полосы-ускорители.'),
('Две гоночные трассы', 'Sector 3 Run гонит по вечернему оранжевому городу, на трассе 8 чекпоинтов. Ascend Only ведёт всё выше по фиолетовому ночному городу, там 13 чекпоинтов. Перед стартом нажимаешь R, то есть «готов», а счётчик (0/1) показывает, сколько игроков готово. Время идёт, и побеждает самый быстрый.'),
('На чём играть', 'Игра работает только на компьютере, на телефоне она не запустится. Управлять можно клавиатурой и мышью или геймпадом вроде Xbox.'),
('Песочница и Backrooms', 'Ещё из хаба можно уйти в песочницу, где просто отрабатываешь движение, и на карту Backrooms.'),
('Под капотом', 'Игру я сделал сам на движке Godot 4 и выложил на свой сервер. При первом запуске браузер загружает около 40 МБ, зато потом всё работает быстро.')]
stat_html = ''.join(f'<li class="stat"><b data-count="{n}">{n}</b><span>{E(l)}</span></li>' for n, l in stats)
body = ''.join(f'<h2>{E(t)}</h2><p>{E(p)}</p>' for t, p in secs)
h = head(title, desc, 'projects-amans-world.jpg')
h += f'''
<main class="page page--wide">
<p class="back"><a href="/projects/">← Проекты</a></p>
<header class="case-head">
<ul class="meta"><li>3D</li><li>в браузере</li><li>только на ПК</li><li>Godot 4</li></ul>
<h1>{E(title)}</h1>
<p class="lead">Паркур и гонки в неоновом городе. Запускается прямо в браузере.</p>
<div class="actions"><a class="btn btn--primary" href="{GAME}" target="_blank" rel="noopener">Играть</a><p class="note">Работает только на компьютере. Можно играть с геймпадом (Xbox).</p></div>
</header>
<div class="gallery gallery--one">
{fig('game-ascend','Ascend Only: старт трассы вверх, 13 чекпоинтов', 'cover', True)} </div>
<ul class="case-stats">{stat_html}</ul>
<section class="case-body">
<p class="intro">Игра, в которую можно зайти по ссылке. Без установки и без регистрации.</p>
{body}
</section>
<section class="case-block" aria-label="Кадры из игры">
<h2 class="block-title">Кадры из игры</h2>
<div class="gallery">
{fig('game-sector3','Sector 3 Run: старт трассы, 8 чекпоинтов')}{fig('game-hub','Хаб: порталы Sandbox, Backrooms, Race и Race 2')}{fig('game-splash','Заставка игры', 'wide')} </div>
</section>
<aside class="soon">
<p class="hello">Готов проверить?</p>
<p>Зайди, выбери портал и попробуй пробежать трассу. Перед гонкой не забудь нажать R.</p>
<div class="actions"><a class="btn btn--primary" href="{GAME}" target="_blank" rel="noopener">Играть в Aman's World</a></div>
</aside>
</main>
''' + TAIL
os.makedirs(W + 'projects/amans-world', exist_ok=True)
open(W + 'projects/amans-world/index.html', 'w').write(h)
CARD2 = ''' <a class="work work--lead" href="/projects/homelab/">
<div class="pic"><img src="/img/proj/homelab-cover.svg" width="1200" height="630" alt="Иллюстрация: домашний сервер с тремя сервисами" loading="lazy" decoding="async"></div>
<div class="info">
<ul class="meta"><li>дома</li><li>Linux</li><li>Fedora</li></ul>
<h2>Хоумлаб</h2>
<p>Мой домашний сервер: файлы, игра и эксперименты. И Linux, на котором я всё делаю.</p>
<span class="more">Смотреть проект <span aria-hidden="true">→</span></span>
</div>
</a>
'''
# ---------- список проектов ----------
w0, h0 = dims('game-ascend')
idx = head('Проекты', "Мои проекты: Aman's World, 3D-игра с паркуром и гонками, которая запускается прямо в браузере.", 'projects.jpg')
idx += f'''
<main class="page page--wide">
<header class="page-intro">
<p class="hello">Проекты</p>
<h1>Что я делаю</h1>
<p class="lead">Кроме учёбы и работы я собираю свои штуки. Выбери проект и загляни внутрь.</p>
</header>
<section class="works" aria-label="Проекты">
<a class="work work--lead" href="/projects/amans-world/">
<div class="pic"><img src="/img/proj/game-ascend-md.webp" srcset="{srcset('game-ascend')}" sizes="(min-width: 62rem) 50vw, calc(100vw - 2.2rem)" width="{w0}" height="{h0}" alt="Aman's World: старт трассы Ascend Only" fetchpriority="high"></div>
<div class="info">
<ul class="meta"><li>3D</li><li>в браузере</li><li>только на ПК</li><li>Godot 4</li></ul>
<h2>Aman's World</h2>
<p>Паркур и гонки в неоновом городе. Запускается прямо в браузере, ничего ставить не надо.</p>
<span class="more">Смотреть проект <span aria-hidden="true">→</span></span>
</div>
</a>
''' + CARD2 + ''' </section>
<aside class="soon">
<p class="hello">Дальше</p>
<p>Тут появятся новые проекты, как только доделаю. Заглядывай.</p>
</aside>
</main>
''' + TAIL
open(W + 'projects/index.html', 'w').write(idx)
# ---------- страница хоумлаба ----------
hl_title = 'Хоумлаб'
hl_desc = 'Мой домашний сервер и Fedora Linux: Nextcloud, моя игра и эксперименты, на которых я учусь DevOps.'
hl = head(hl_title, hl_desc, 'projects-homelab.jpg')
hl += '''
<main class="page page--wide">
<p class="back"><a href="/projects/">← Проекты</a></p>
<header class="case-head">
<ul class="meta"><li>дома</li><li>Linux</li><li>Fedora</li></ul>
<h1>Хоумлаб</h1>
<p class="lead">Мой домашний сервер и Linux, на котором я всё делаю.</p>
</header>
<div class="gallery gallery--one">
<figure class="shot shot--cover shot--lead">
<a href="/img/proj/homelab-cover.svg" target="_blank" rel="noopener" aria-label="Открыть иллюстрацию крупно"><img src="/img/proj/homelab-cover.svg" width="1200" height="630" alt="Иллюстрация: домашний сервер с тремя сервисами" fetchpriority="high"></a>
</figure>
</div>
<section class="case-body">
<p class="intro">Свой маленький интернет у меня дома.</p>
<h2>Что это</h2>
<p>Хоумлаб — это мой домашний сервер. Я работаю системным администратором и расту в сторону DevOps, поэтому держу дома место, где можно пробовать всё на настоящем железе.</p>
<p>Дома можно спокойно пробовать, ломать и чинить. Так я учусь автоматизации и всему, что окружает современный сервер.</p>
</section>
<section class="case-block" aria-label="Что живёт на сервере">
<h2 class="block-title">Что на нём живёт</h2>
<div class="tiles">
<div class="tile"><h3>Nextcloud</h3><p>Мои файлы и проекты лежат у меня, а не в чужом облаке.</p></div>
<a class="tile" href="/projects/amans-world/"><h3>Aman's World</h3><p>Моя браузерная игра запускается с этого сервера.</p><span class="more">Смотреть игру →</span></a>
<div class="tile"><h3>DuckDNS</h3><p>Бесплатные адреса, чтобы до домашнего сервера можно было достучаться снаружи.</p></div>
<div class="tile"><h3>Этот сайт</h3><p>Он тоже переезжает сюда. Выкладку хочу автоматизировать через CI.</p></div>
<div class="tile tile--more"><h3>И много чего ещё</h3><p>Список растёт: я постоянно что-то поднимаю и пробую.</p></div>
</div>
</section>
<section class="case-body">
<h2>Fedora Linux</h2>
<p>На своём компьютере я пользуюсь Fedora Linux. В ней я учусь, работаю и делаю свои проекты, в том числе этот сайт.</p>
</section>
</main>
''' + TAIL
os.makedirs(W + 'projects/homelab', exist_ok=True)
open(W + 'projects/homelab/index.html', 'w').write(hl)
# sitemap: дописываем недостающие адреса
sm_path = W + 'sitemap.xml'
sm = open(sm_path).read()
for u in ['/projects/', '/projects/amans-world/', '/projects/homelab/']:
if f'<loc>https://example.com{u}</loc>' not in sm:
sm = sm.replace('</urlset>', f' <url><loc>https://example.com{u}</loc></url>\n</urlset>')
open(sm_path, 'w').write(sm)
print('projects pages: ok')