Compare commits
14 Commits
638c7162e7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bba9a39a6b | |||
| 800f93a22f | |||
| 92b7094fc7 | |||
| 07ba1e427d | |||
| f7be5a87b4 | |||
| 65457becf2 | |||
| 2359b52eca | |||
| 78cb3b4ea0 | |||
| 462a61151b | |||
| a8fde20e38 | |||
| f03935efb7 | |||
| 4eae3eb7aa | |||
| 9cda51fa92 | |||
| 2d8ac65a6a |
@@ -17,10 +17,14 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
apt-get update -qq
|
apt-get update -qq
|
||||||
apt-get install -y -qq make python3 python3-venv python3-pip hugo
|
apt-get install -y -qq python3 python3-venv python3-paramiko hugo
|
||||||
|
|
||||||
- name: Deploy via SFTP
|
- name: Deploy via SFTP
|
||||||
env:
|
env:
|
||||||
SSHPASS: ${{ secrets.SSHPASS }}
|
SSHPASS: ${{ secrets.SSHPASS }}
|
||||||
run: |
|
run: |
|
||||||
make deploy
|
set -e
|
||||||
|
hugo --minify
|
||||||
|
echo "Сайт успешно собран в папку public/"
|
||||||
|
test -n "$SSHPASS" || { echo "ОШИБКА: переменная SSHPASS (пароль SFTP) не задана. См. README.md / docs/DEPLOY_INSTRUCTIONS.md"; exit 1; }
|
||||||
|
python3 tools/deploy_sftp.py
|
||||||
@@ -13,3 +13,5 @@ static/images/favicon/dwarffortress*
|
|||||||
static/images/favicon/android-chrome*
|
static/images/favicon/android-chrome*
|
||||||
static/images/favicon/site.webmanifest
|
static/images/favicon/site.webmanifest
|
||||||
static/images/favicon/apple-touch-icon.png
|
static/images/favicon/apple-touch-icon.png
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
# Определяем операционную систему
|
|
||||||
ifeq ($(OS),Windows_NT)
|
|
||||||
PLATFORM = Windows
|
|
||||||
# На Windows используем WinSCP.com для SFTP
|
|
||||||
WINSCP_PATH = "C:\Program Files (x86)\WinSCP\WinSCP.com"
|
|
||||||
DEPLOY_CMD = $(WINSCP_PATH) /script=winscp_script.txt
|
|
||||||
DRYRUN_CMD = echo "Windows: проверка SFTP-соединения..."
|
|
||||||
else
|
|
||||||
PLATFORM = Unix
|
|
||||||
# На Unix используем paramiko (SFTP). Аккаунт Jino — SFTP-only: rsync/ssh-exec
|
|
||||||
# запрещены ("exec request failed on channel 0"), поэтому deploy через
|
|
||||||
# tools/deploy_sftp.py (рекурсивный mirror + удаление лишнего).
|
|
||||||
# Венв .venv создаётся автоматически таргетом venv (нужен paramiko).
|
|
||||||
DEPLOY_CMD = .venv/bin/python tools/deploy_sftp.py
|
|
||||||
DRYRUN_CMD = .venv/bin/python tools/deploy_sftp.py --dry-run
|
|
||||||
# Пароль берётся из переменной окружения SSHPASS (см. README.md / docs/DEPLOY_INSTRUCTIONS.md)
|
|
||||||
endif
|
|
||||||
.PHONY: build deploy deploy-dry-run clean rebuild help venv
|
|
||||||
|
|
||||||
build:
|
|
||||||
hugo --minify
|
|
||||||
@echo "Сайт успешно собран в папку public/"
|
|
||||||
|
|
||||||
# ЕДИНСТВЕННЫЙ способ деплоя — через make deploy.
|
|
||||||
# Никогда не заменять на rsync/ssh-exec напрямую: на Jino аккаунт SFTP-only.
|
|
||||||
deploy: build venv
|
|
||||||
ifeq ($(PLATFORM),Windows)
|
|
||||||
$(DEPLOY_CMD)
|
|
||||||
else
|
|
||||||
@test -n "$$SSHPASS" || (echo "ОШИБКА: переменная SSHPASS (пароль SFTP) не задана. См. README.md / docs/DEPLOY_INSTRUCTIONS.md"; exit 1)
|
|
||||||
$(DEPLOY_CMD)
|
|
||||||
endif
|
|
||||||
|
|
||||||
deploy-dry-run: build venv
|
|
||||||
ifeq ($(PLATFORM),Windows)
|
|
||||||
@echo "Выполняем проверку соединения с сервером..."
|
|
||||||
$(DRYRUN_CMD)
|
|
||||||
else
|
|
||||||
@test -n "$$SSHPASS" || (echo "ОШИБКА: переменная SSHPASS (пароль SFTP) не задана. См. README.md / docs/DEPLOY_INSTRUCTIONS.md"; exit 1)
|
|
||||||
$(DRYRUN_CMD)
|
|
||||||
endif
|
|
||||||
|
|
||||||
# Создаёт venv с paramiko для Unix-деплоя (единожды)
|
|
||||||
venv:
|
|
||||||
@if [ ! -x .venv/bin/python ]; then \
|
|
||||||
echo "Создаю .venv и ставлю paramiko..."; \
|
|
||||||
python3 -m venv .venv && .venv/bin/pip install -q paramiko; \
|
|
||||||
fi
|
|
||||||
clean:
|
|
||||||
ifeq ($(OS),Windows_NT)
|
|
||||||
del /q /s public\/
|
|
||||||
else
|
|
||||||
rm -rf public/
|
|
||||||
endif
|
|
||||||
@echo "Папка public удалена"
|
|
||||||
|
|
||||||
rebuild: clean deploy
|
|
||||||
|
|
||||||
help:
|
|
||||||
@echo "Доступные команды:"
|
|
||||||
@echo " make build - собрать сайт"
|
|
||||||
@echo " make deploy - собрать и задеплоить сайт (SFTP через lftp, требуется SSHPASS)"
|
|
||||||
@echo " make deploy-dry-run - симуляция деплоя (требуется SSHPASS)"
|
|
||||||
@echo " make clean - удалить папку public"
|
|
||||||
@echo " make rebuild - полная пересборка сайта"
|
|
||||||
@echo " make help - показать это сообщение"
|
|
||||||
@echo ""
|
|
||||||
@echo "Деплой: export SSHPASS='<пароль SFTP>'; make deploy"
|
|
||||||
@echo "Пароль хранится в docs/DEPLOY_INSTRUCTIONS.md (winscp_script.txt для Windows)."
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
title: "Архив"
|
||||||
|
description: "Все посты блога, сгруппированные по годам и месяцам"
|
||||||
|
layout: archives
|
||||||
|
---
|
||||||
+38
-71
@@ -3,107 +3,74 @@
|
|||||||
## Описание
|
## Описание
|
||||||
|
|
||||||
Этот документ описывает процесс публикации сгенерированного сайта (папка `public`)
|
Этот документ описывает процесс публикации сгенерированного сайта (папка `public`)
|
||||||
на хостинге через SFTP. Решение кроссплатформенное и работает как на Unix-подобных
|
на хостинге через SFTP.
|
||||||
системах (Linux/macOS), так и на Windows.
|
|
||||||
|
|
||||||
> **ВАЖНО: деплой сайта выполняется ТОЛЬКО через `make deploy`.** Аккаунт Jino —
|
**Деплой автоматизирован через Gitea Actions.** Публикация выполняется
|
||||||
> SFTP-only: удалённый запуск команд (rsync, ssh-exec) запрещён сервером
|
автоматически при каждом пуше в ветку `main` репозитория или вручную через
|
||||||
> (ошибка `exec request failed on channel 0`). Прямой rsync на этот аккаунт
|
Workflow Dispatch в Gitea UI (Actions -> deploy-dedinit -> Run workflow).
|
||||||
> НЕ РАБОТАЕТ.
|
|
||||||
|
|
||||||
## Учетные данные и параметры подключения
|
## Учетные данные и параметры подключения
|
||||||
|
|
||||||
- **Хост**: kpa39l.myjino.ru
|
- **Хост**: kpa39l.myjino.ru
|
||||||
- **Порт SFTP**: 2222
|
- **Порт SFTP**: 2222
|
||||||
- **Пользователь**: kpa39l_dedinit
|
- **Пользователь**: kpa39l_dedinit
|
||||||
- **Пароль**: -Zp4ep747t25
|
- **Пароль**: хранится в секрете Gitea `SSHPASS` (Settings -> Actions -> Secrets
|
||||||
|
репозитория). В открытом виде в репозитории **не хранится**.
|
||||||
- **Целевой путь**: /dedinit.ru (относительно корня аккаунта)
|
- **Целевой путь**: /dedinit.ru (относительно корня аккаунта)
|
||||||
|
|
||||||
## Установка зависимостей
|
## Как работает деплой
|
||||||
|
|
||||||
### Для Linux/macOS
|
1. Workflow `.gitea/workflows/deploy.yml` (события: `push` в `main`
|
||||||
|
и `workflow_dispatch`).
|
||||||
|
2. Шаги:
|
||||||
|
- **Checkout** — `actions/checkout@v4` (самодостаточный зеркальный экшен,
|
||||||
|
без обращения к github.com);
|
||||||
|
- **Install dependencies** — `python3`, `python3-venv`, `python3-pip`, `hugo`;
|
||||||
|
- **Deploy via SFTP** — сборка `hugo --minify`, создание `.venv` с
|
||||||
|
`paramiko`, проверка секрета `SSHPASS` и запуск
|
||||||
|
`tools/deploy_sftp.py` (рекурсивный mirror + удаление устаревших файлов).
|
||||||
|
|
||||||
- **Python 3** (с venv/pip)
|
> **ВАЖНО: аккаунт Jino — SFTP-only:** удалённый запуск команд (rsync,
|
||||||
- **Make** — для выполнения команд из Makefile
|
> ssh-exec) запрещён сервером (ошибка `exec request failed on channel 0`).
|
||||||
- Пакет `paramiko` ставится автоматически в `.venv/` при первом `make deploy`
|
> Прямой rsync на этот аккаунт НЕ РАБОТАЕТ — только `tools/deploy_sftp.py`.
|
||||||
(таргет `venv`), либо вручную: `pip install paramiko`
|
|
||||||
|
|
||||||
### Для Windows
|
## Запуск вручную
|
||||||
|
|
||||||
- **WinSCP** — установите с официального сайта (<https://winscp.net/>)
|
В Gitea: открыть репозиторий -> Actions -> workflow `deploy-dedinit` ->
|
||||||
- **Make** — можно установить через Chocolatey, MinGW или Git for Windows
|
кнопка **Run workflow** (ветка `main`).
|
||||||
|
|
||||||
## Использование Makefile
|
## Локальный запуск (без Actions)
|
||||||
|
|
||||||
### Сборка сайта
|
Только для отладки. Требуется заранее заданный секрет:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make build
|
export SSHPASS='<пароль из секрета Gitea SSHPASS>'
|
||||||
|
hugo --minify
|
||||||
|
python3 -m venv .venv && .venv/bin/pip install -q paramiko
|
||||||
|
.venv/bin/python tools/deploy_sftp.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Команда генерирует сайт в папку `public/`.
|
## Симуляция деплоя (dry-run)
|
||||||
|
|
||||||
### Деплой сайта
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# один раз экспортируем пароль SFTP (каждый раз в новой сессии)
|
export SSHPASS='<пароль из секрета Gitea SSHPASS>'
|
||||||
export SSHPASS='-Zp4ep747t25'
|
.venv/bin/python tools/deploy_sftp.py --dry-run
|
||||||
make deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
ЕДИНСТВЕННЫЙ способ публикации. Сначала собирает сайт, затем зеркалирует
|
|
||||||
`public/` на сервер (`/dedinit.ru`) с удалением устаревших файлов
|
|
||||||
(аналог `rsync --delete`):
|
|
||||||
|
|
||||||
- **Unix**: `tools/deploy_sftp.py` (paramiko, SFTP + рекурсивный mirror)
|
|
||||||
- **Windows**: WinSCP.com (скрипт `winscp_script.txt`)
|
|
||||||
|
|
||||||
Если `SSHPASS` не задана — `make deploy` завершится с ошибкой и подсказкой.
|
|
||||||
|
|
||||||
### Симуляция деплоя
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export SSHPASS='-Zp4ep747t25'
|
|
||||||
make deploy-dry-run
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Собирает сайт и показывает, какие файлы будут загружены/удалены, без заливки.
|
Собирает сайт и показывает, какие файлы будут загружены/удалены, без заливки.
|
||||||
|
|
||||||
### Очистка
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make clean
|
|
||||||
```
|
|
||||||
|
|
||||||
Команда удаляет папку `public/`.
|
|
||||||
|
|
||||||
### Полная пересборка
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make rebuild
|
|
||||||
```
|
|
||||||
|
|
||||||
Команда удаляет папку `public/`, затем заново собирает и деплоит сайт.
|
|
||||||
|
|
||||||
### Справка
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make help
|
|
||||||
```
|
|
||||||
|
|
||||||
Показывает список доступных команд.
|
|
||||||
|
|
||||||
## Конфигурация
|
## Конфигурация
|
||||||
|
|
||||||
Параметры подключения для Unix находятся в `tools/deploy_sftp.py`
|
Параметры подключения находятся в `tools/deploy_sftp.py`
|
||||||
(переменные `DEDINIT_HOST`, `DEDINIT_PORT`, `DEDINIT_USER`, `DEDINIT_REMOTE`,
|
(переменные `DEDINIT_HOST`, `DEDINIT_PORT`, `DEDINIT_USER`, `DEDINIT_REMOTE`,
|
||||||
по умолчанию — kpa39l.myjino.ru:2222 / kpa39l_dedinit / /dedinit.ru).
|
по умолчанию — kpa39l.myjino.ru:2222 / kpa39l_dedinit / /dedinit.ru).
|
||||||
На Windows — в `Makefile` и `winscp_script.txt`.
|
|
||||||
|
|
||||||
При необходимости вы можете изменить эти параметры.
|
При необходимости вы можете изменить эти параметры.
|
||||||
|
|
||||||
## Настройка для Windows
|
## Управление паролем (секрет SSHPASS)
|
||||||
|
|
||||||
1. Убедиться, что WinSCP установлен в стандартную директорию
|
- Пароль задан один раз через Gitea API:
|
||||||
`C:\Program Files (x86)\WinSCP\`
|
`PUT /api/v1/repos/estorozhenko/dedinit.ru/actions/secrets/SSHPASS`.
|
||||||
2. Файл `winscp_script.txt` содержит команды для подключения и синхронизации файлов
|
- Обновить/посмотреть наличие: Gitea UI -> Settings -> Actions -> Secrets.
|
||||||
|
- Workflow читает его как `${{ secrets.SSHPASS }}` и передаёт в переменную
|
||||||
|
окружения `SSHPASS` шагу Deploy via SFTP.
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
schema: spec-driven
|
schema: spec-driven
|
||||||
created: 2026-09-12
|
created: 2026-09-22
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Design: Archive by Year/Month
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Страница `/posts/` (меню «Архив») показывает посты, сгруппированные по
|
||||||
|
годам и месяцам (как штатный архив Hugo/PaperMod), вместо плоского списка.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
Используем готовый layout темы rDedInit: `themes/rDedInit/layouts/_default/archives.html`.
|
||||||
|
|
||||||
|
Что он делает (из исходника theme):
|
||||||
|
- `where site.RegularPages "Type" "in" site.Params.mainSections` — берёт
|
||||||
|
обычные страницы основных секций (не список, без пагинации).
|
||||||
|
- `GroupByPublishDate "2006"` — группировка по годам.
|
||||||
|
- Внутри каждого года `GroupByDate "January"` — группировка по месяцам.
|
||||||
|
- Заголовки `.archive-year` / `.archive-month`, счётчики, якоря `#2026`,
|
||||||
|
`#2026-January`.
|
||||||
|
- Каждая запись — `.archive-entry` с title, post_meta и ссылкой.
|
||||||
|
|
||||||
|
Стили `.archive-*` уже есть в `assets/css/common/archive.css`.
|
||||||
|
|
||||||
|
## Change: content/posts/_index.md
|
||||||
|
|
||||||
|
Создать файл `content/posts/_index.md`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: "Архив"
|
||||||
|
description: "Все посты блога по годам и месяцам"
|
||||||
|
layout: archives
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontmatter `layout: archives` заставляет Hugo выбрать
|
||||||
|
`_default/archives.html` для листинга секции (Hugo ищет layout по
|
||||||
|
порядку: `<type>/<layout>.html`, `_default/<layout>.html` — найдёт
|
||||||
|
`_default/archives.html`).
|
||||||
|
|
||||||
|
Никаких других файлов менять не нужно:
|
||||||
|
- Меню `[menu.main]` Архив → `/posts/` уже настроено (hugo.toml).
|
||||||
|
- RSS секции `/posts/` рендерится через `rss.xml` независимо.
|
||||||
|
- `baseof.html` уже поддерживает `eq .Layout "archives"` для
|
||||||
|
breadcrumbs/навигации.
|
||||||
|
- `index.json` (поиск) и `rss.xml` исключают layout `archives` из листинга.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. `make build` (hugo --minify)
|
||||||
|
2. `grep -c 'archive-year' public/posts/index.html` > 0
|
||||||
|
3. `grep -c 'archive-month' public/posts/index.html` > 0
|
||||||
|
4. Убедиться, что title на странице — «Архив», посты сгруппированы по годам
|
||||||
|
5. `make deploy-dry-run` — SFTP-симуляция (деплой: Gitea Actions)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Archive by Year/Month
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Страница «Архив» (меню → `/posts/`) сейчас показывает плоский список всех
|
||||||
|
постов с пагинацией по 10 (layout `list.html`). При ~100+ постах это
|
||||||
|
неудобно: нет обзора по годам и месяцам, нельзя быстро найти пост по
|
||||||
|
периоду.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Hugo умеет группировать посты по годам/месяцам из коробки (GroupByDate /
|
||||||
|
GroupByPublishDate). Тема rDedInit уже содержит готовый layout
|
||||||
|
`_default/archives.html` и стили `archive.css` — нужно лишь переключить
|
||||||
|
секцию `/posts/` на этот layout. Расширять тему не требуется.
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
Добавить `content/posts/_index.md` с frontmatter `layout: archives` (и
|
||||||
|
title/description). Hugo автоматически применит `archives.html` к листингу
|
||||||
|
секции `/posts/`, который сгруппирует посты по годам и месяцам с
|
||||||
|
счётчиками и якорями.
|
||||||
|
|
||||||
|
## Alternative Solutions
|
||||||
|
|
||||||
|
- Написать свой layout `layouts/posts/list.html` — избыточно, тема уже
|
||||||
|
имеет готовый архив.
|
||||||
|
- Использовать отдельную страницу `/archive/` — меняет URL и меню; текущий
|
||||||
|
пункт меню «Архив» ведёт на `/posts/`, менять не нужно.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Изменяется только рендер `/posts/` (list → archives).
|
||||||
|
- URL постов и permalinks не меняются.
|
||||||
|
- RSS секции `/posts/` сохраняется (archives.html не влияет на rss.xml).
|
||||||
|
- Деплой — автоматический через Gitea Actions (push в main).
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
Страница «Архив» (/posts/) блога dedinit.ru отображает все посты,
|
||||||
|
сгруппированные по годам и месяцам (как штатный архив Hugo), вместо
|
||||||
|
плоского списка с пагинацией.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Группировка постов по годам и месяцам
|
||||||
|
Система SHALL отображать на странице /posts/ список всех постов,
|
||||||
|
сгруппированный по годам публикации, и внутри каждого года — по месяцам
|
||||||
|
публикации. Заголовки содержат год/месяц и количество постов в группе.
|
||||||
|
|
||||||
|
#### Scenario: Страница /posts/ показывает годы
|
||||||
|
- **WHEN** пользователь открывает страницу /posts/
|
||||||
|
- **THEN** страница содержит заголовки годов (например «2026», «2023»), под каждым — посты этого года
|
||||||
|
- **AND** возле каждого года отображается количество постов
|
||||||
|
|
||||||
|
#### Scenario: Внутри года посты сгруппированы по месяцам
|
||||||
|
- **WHEN** пользователь открывает страницу /posts/
|
||||||
|
- **THEN** внутри каждого года посты сгруппированы по месяцам публикации (например «January», «September»)
|
||||||
|
- **AND** возле каждого месяца отображается количество постов
|
||||||
|
|
||||||
|
#### Scenario: Каждый пост — ссылка на свою страницу
|
||||||
|
- **WHEN** пользователь видит запись в архиве
|
||||||
|
- **THEN** заголовок поста является ссылкой на его страницу (permalink)
|
||||||
|
- **AND** под заголовком отображаются метаданные поста (дата и пр.)
|
||||||
|
|
||||||
|
### Requirement: Заголовок и описание страницы
|
||||||
|
Система SHALL отображать на странице /posts/ заголовок «Архив» и описание
|
||||||
|
страницы (из frontmatter секции).
|
||||||
|
|
||||||
|
#### Scenario: Заголовок страницы
|
||||||
|
- **WHEN** пользователь открывает страницу /posts/
|
||||||
|
- **THEN** заголовок страницы — «Архив»
|
||||||
|
|
||||||
|
### Requirement: Сохранение остальной функциональности секции
|
||||||
|
Система SHALL сохранять работоспособность RSS-ленты секции /posts/ и URL
|
||||||
|
постов (permalinks не меняются) при переключении layout на архив.
|
||||||
|
|
||||||
|
#### Scenario: RSS секции не сломан
|
||||||
|
- **WHEN** пользователь запрашивает RSS-ленту секции /posts/ (index.xml)
|
||||||
|
- **THEN** лента валидна и содержит посты секции
|
||||||
|
|
||||||
|
#### Scenario: Permalinks постов не меняются
|
||||||
|
- **WHEN** пользователь открывает пост из архива
|
||||||
|
- **THEN** URL поста соответствует permalink /:year/:slug/ (как и раньше)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Tasks: Archive by Year/Month
|
||||||
|
|
||||||
|
- [x] Создать `content/posts/_index.md` с frontmatter: title "Архив", description, layout: archives
|
||||||
|
- [x] `make build` (hugo --minify) — без ошибок
|
||||||
|
- [x] Проверить `public/posts/index.html`: есть `.archive-year` и `.archive-month`, посты сгруппированы по годам/месяцам, title "Архив"
|
||||||
|
- [x] Убедиться, что пагинация/плоский список (post-entry) на /posts/ исчезла
|
||||||
|
- [x] Проверить RSS секции /posts/ (index.xml) — не сломан
|
||||||
|
- [x] `openspec validate archive-by-year-month` — чисто
|
||||||
|
- [x] Commit + push gitea main — деплой через Gitea Actions (deploy.yml); замер №1: run создан 16:54:19Z, прод виден 17:30:20Z (~36 мин)
|
||||||
|
- [x] Прогнать openspec-archive-change (архивировать change после деплоя) — архив 2026-09-25
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-09-22
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Design: Инкрементальный SFTP-деплой
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
|
||||||
|
`tools/deploy_sftp.py` mirror-заливал ВСЕ 524 файла (66M) каждый деплой,
|
||||||
|
включая неизменные тяжёлые (21M PDF, 11M×2 M4V) — деплой занимал ~36 мин
|
||||||
|
(замер №1: run создан 16:54:19Z → прод виден 17:30:20Z). Плюс: при обрыве
|
||||||
|
SFTP-соединения скрипт вис бесконечно (нет таймаутов) — оба раза висел на
|
||||||
|
`2026/gotosocial-relay-match-by-default/hero.svg`.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Единственный файл `tools/deploy_sftp.py`:
|
||||||
|
|
||||||
|
1. **Инкремент по (size + SHA1)**: для каждого локального файла — `sftp.stat()`;
|
||||||
|
если удалённый существует и (размер совпадает И SHA1 содержимого совпадает)
|
||||||
|
— пропуск; иначе `sftp.put()`.
|
||||||
|
- mtime НЕ используется: в CI (Gitea Actions) checkout ставит свежий mtime
|
||||||
|
всем файлам → mtime-сравнение бесполезно.
|
||||||
|
- SHA1 читается потоково с удалённого через `sftp.open()` (1MB чанками) —
|
||||||
|
корректно для 21M PDF (несколько секунд).
|
||||||
|
- Для несовпадающих размеров хэш не считается (быстрый путь).
|
||||||
|
|
||||||
|
2. **Защита от зависаний**:
|
||||||
|
- `Transport.set_keepalive(15)` — пинги; мёртвое соединение падает само.
|
||||||
|
- `sftp.get_channel().settimeout(120)` — операция дольше 120с прерывается.
|
||||||
|
- `with_retry()` — при исключении переподключение (до 3 попыток, sleep 2с),
|
||||||
|
`put`/`remove` повторяются; после исчерпания — выход с ненулевым кодом.
|
||||||
|
|
||||||
|
3. **Mirror-удаление сохранено**: `remote_files - local_files` → `remove()`.
|
||||||
|
|
||||||
|
4. **CI**: `python3-paramiko` ставится через **apt** (не pip в venv) — не зависит
|
||||||
|
от PyPI, быстрее (установка случайно занимала 15-20 мин).
|
||||||
|
|
||||||
|
## Почему не mtime
|
||||||
|
|
||||||
|
В Gitea Actions `actions/checkout` распаковывает репозиторий с текущим mtime →
|
||||||
|
все файлы «свежие» относительно сервера → mtime-сравнение никогда не пропускает.
|
||||||
|
SHA1 — единственный надёжный индикатор неизменности для CI.
|
||||||
|
|
||||||
|
## Альтернативы, отклонённые
|
||||||
|
|
||||||
|
- **rsync**: Jino SFTP-only, удалённый exec запрещён.
|
||||||
|
- **Манифест (JSON) прошлого деплоя**: усложняет, требует хранения состояния;
|
||||||
|
SHA1-сравнение с сервером самодостаточно.
|
||||||
|
- **Только размер**: риск ложного пропуска при изменении содержимого без смены
|
||||||
|
размера (маловероятно для статики, но SHA1 дешевле ошибки).
|
||||||
|
|
||||||
|
## Открытые вопросы
|
||||||
|
|
||||||
|
- Точное время залива (замер №2) зависит от пропускной способности Jino SFTP.
|
||||||
|
- SHA1 крупных файлов при каждом деплое: 21M PDF → ~2-5с на чтение с сервера;
|
||||||
|
приемлемо по сравнению с перезаливкой.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
id: deploy-sftp-incremental
|
||||||
|
created: 2026-09-22
|
||||||
|
schema: spec-driven
|
||||||
|
---
|
||||||
|
|
||||||
|
# Proposal: Инкрементальный SFTP-деплой (только изменившиеся файлы)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`tools/deploy_sftp.py` при каждом деплое заливает ВСЕ файлы из `public/` (524 файла, 66M)
|
||||||
|
через `sftp.put()` без проверки, изменился ли файл на сервере. Из них ~40M — тяжёлые
|
||||||
|
неизменные файлы (PDF 21M, M4V 11M×2 и др.), которые льются повторно каждый раз.
|
||||||
|
|
||||||
|
Результат (замер №1, 2026-09-22): деплой через Gitea Actions занял **~36 минут** до
|
||||||
|
видимости на проде. Плюс скрипт не имеет таймаутов: при обрыве SFTP-соединения
|
||||||
|
`paramiko.Transport` висит в `sftp.put()` бесконечно (наблюдали на run 40:
|
||||||
|
лог замер на 17:22:54, файл hero.svg 1.7KB не залит, job не завершился).
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
- Заливать только файлы, которых нет на сервере или которые изменились (size+mtime).
|
||||||
|
- Не висеть вечно: таймауты на соединение и на операцию, явная ошибка при обрыве.
|
||||||
|
- Сократить время деплоя в разы (цель: < 5 мин до видимости на проде).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Не трогаем способ вызова (по-прежнему `gitea-main push` → Gitea Actions → `tools/deploy_sftp.py`).
|
||||||
|
- Не трогаем содержание сайта / архив / темы.
|
||||||
|
- Не добавляем rsync (на Jino SFTP-only, удалённый exec запрещён).
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
# Spec: Инкрементальный SFTP-деплой
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Скрипт заливает только изменившиеся файлы
|
||||||
|
|
||||||
|
Скрипт сравнивает каждый локальный файл с удалённым и заливает только те, что отсутствуют на сервере или отличаются. Сравнение — по размеру и SHA1-хешу содержимого (mtime не используется: в CI после checkout он всегда свежий, что делает mtime-сравнение бесполезным).
|
||||||
|
|
||||||
|
#### Scenario: Файл уже есть на сервере и не менялся
|
||||||
|
|
||||||
|
Given сервер содержит файл `X` с тем же размером и SHA1, что локальный
|
||||||
|
And локальный файл `X` существует
|
||||||
|
When выполняется `tools/deploy_sftp.py`
|
||||||
|
Then файл `X` НЕ заливается на сервер
|
||||||
|
|
||||||
|
#### Scenario: Файла нет на сервере
|
||||||
|
|
||||||
|
Given сервер НЕ содержит файл `X`
|
||||||
|
And локальный файл `X` существует
|
||||||
|
When выполняется `tools/deploy_sftp.py`
|
||||||
|
Then файл `X` заливается на сервер
|
||||||
|
|
||||||
|
#### Scenario: Файл изменился (другой размер)
|
||||||
|
|
||||||
|
Given сервер содержит файл `X` с размером 100
|
||||||
|
And локальный файл `X` имеет размер 125
|
||||||
|
When выполняется `tools/deploy_sftp.py`
|
||||||
|
Then файл `X` заливается на сервер
|
||||||
|
|
||||||
|
#### Scenario: Файл изменился (тот же размер, другое содержимое)
|
||||||
|
|
||||||
|
Given сервер содержит файл `X` того же размера, но с другим SHA1
|
||||||
|
And локальный файл `X` имеет другой SHA1
|
||||||
|
When выполняется `tools/deploy_sftp.py`
|
||||||
|
Then файл `X` заливается на сервер
|
||||||
|
|
||||||
|
### Requirement: Скрипт не виснет при обрыве соединения
|
||||||
|
|
||||||
|
Скрипт использует keepalive (15с) и таймаут канала (120с), а также переподключение с ретраями (до 3 попыток), чтобы при обрыве канала завершиться с ошибкой или продолжить, а не висеть бесконечно.
|
||||||
|
|
||||||
|
#### Scenario: Соединение с сервером оборвалось
|
||||||
|
|
||||||
|
Given SFTP-соединение с Jino работает
|
||||||
|
And соединение обрывается во время `put()`
|
||||||
|
When выполняется `tools/deploy_sftp.py`
|
||||||
|
Then скрипт переподключается и повторяет операцию; если все попытки исчерпаны — завершается с ненулевым кодом и сообщением об ошибке
|
||||||
|
|
||||||
|
### Requirement: Удаление лишних файлов сохранено
|
||||||
|
|
||||||
|
Скрипт продолжает удалять с сервера файлы, которых нет в локальном `public/` (mirror-поведение).
|
||||||
|
|
||||||
|
#### Scenario: На сервере есть файл, которого нет локально
|
||||||
|
|
||||||
|
Given сервер содержит файл `Y`
|
||||||
|
And локальный каталог `public/` не содержит `Y`
|
||||||
|
When выполняется `tools/deploy_sftp.py`
|
||||||
|
Then файл `Y` удаляется с сервера
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Tasks: Инкрементальный SFTP-деплой
|
||||||
|
|
||||||
|
- [x] Обновить `tools/deploy_sftp.py`: инкремент по (size + SHA1), keepalive/таймаут канала, ретраи с переподключением
|
||||||
|
- [x] Проверить синтаксис (`python3 -m py_compile`) и логику same_file (юнит-тест без сети)
|
||||||
|
- [x] CI: заменить pip-установку paramiko на apt `python3-paramiko` (не зависит от PyPI) в `.gitea/workflows/deploy.yml`
|
||||||
|
- [x] Обновить openspec (spec/design/tasks) под SHA1-решение
|
||||||
|
- [x] Замерить деплой №2 после запуска run на новом коде: сравнить с эталоном ~36 мин
|
||||||
|
- [x] Прогнать openspec-archive-change (из прошлого change) — замер №1 зафиксирован в tasks.md
|
||||||
|
|
||||||
|
## Замеры
|
||||||
|
|
||||||
|
- **Замер №1 (эталон, старый mirror-скрипт)**: run 43 создан 16:54:19Z → прод виден 17:30:20Z = **~36 мин**. Полный job не завершился (завис на мёртвом SFTP).
|
||||||
|
- **Замер №1b (f7be5a8, mtime-инкремент — НЕ сработал)**: run 41 создан 18:40:54Z → прод обновлён 19:07:07Z = **~27 мин** (ускорение за счёт того, что заливка шла быстрее; mtime-сравнение пропускало 0 файлов — checkout даёт свежий mtime). Job завис на том же `2026/gotosocial-relay-match-by-default/hero.svg`.
|
||||||
|
- **Замер №2 (SHA1 + apt-paramiko)**: run 43 (92b7094) — SUCCESS за **12.7 мин** (19:37:09 → 19:49:50Z), «Готово: 0 залито, 482 пропущено» при неизменном контенте. Выигрыш: ~36 мин → ~12-13 мин на пустой деплой; при изменениях льётся только diff вместо 66M.
|
||||||
|
|
||||||
|
## Блокеры
|
||||||
|
|
||||||
|
- Run 41 (f7be5a8) висит в Gitea Actions на мёртвом SFTP (нет API-cancel; runner на bigbox недоступен) — новый run (07ba1e4-правки) ждёт освобождения runner'а. По умолчанию Gitea ждёт до ~6ч.
|
||||||
|
- Gitea не создаёт следующий run по ветке, пока по ней есть active run (наблюдение).
|
||||||
|
- `socket.setdefaulttimeout(120)` НЕ прерывает зависший put в paramiko (канал создан до вызова) — заменён на `channel.settimeout(120)`.
|
||||||
@@ -79,7 +79,7 @@ jobs:
|
|||||||
```bash
|
```bash
|
||||||
curl -X PUT "https://gitea.nixg.ru/api/v1/repos/estorozhenko/dedinit.ru/actions/secrets/SSHPASS" \
|
curl -X PUT "https://gitea.nixg.ru/api/v1/repos/estorozhenko/dedinit.ru/actions/secrets/SSHPASS" \
|
||||||
-H "Authorization: token $GITEA_NIXG_TOKEN" \
|
-H "Authorization: token $GITEA_NIXG_TOKEN" \
|
||||||
-d '{"value": "-Zp4ep747t25"}'
|
-d '{"value": "***SFTP_PASSWORD***"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Файлы
|
## Файлы
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
## Context
|
|
||||||
|
|
||||||
Проект dedinit.ru — Hugo Extended 0.165, тема rDedInit (форк PaperMod), язык ru-ru. Сейчас `i18n/` отсутствует, UI-строки темы отображаются английскими дефолтами PaperMod. См. proposal.md — Why.
|
|
||||||
|
|
||||||
## Goals / Non-Goals
|
|
||||||
|
|
||||||
**Goals:**
|
|
||||||
- Создать `i18n/ru.toml` с переводами всех ключей, используемых шаблонами rDedInit.
|
|
||||||
- Русский UI без изменения разметки и контента.
|
|
||||||
|
|
||||||
**Non-Goals:**
|
|
||||||
- Не переводим содержимое контента (посты).
|
|
||||||
- Не добавляем мультиязычность (en/ru переключатель) — вне рамок задачи.
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
- **Формат TOML** (Hugo i18n) — стандарт Hugo, `ru.toml` рядом с другими локалями.
|
|
||||||
- **Источник ключей**: извлечены из `themes/rDedInit/layouts/` — `home`, `next_page`, `prev_page`, `read_time`, `words`, `toc`, `code_copy`, `code_copied`, `edit_post`, `translations`; дополнительно стандартные ключи PaperMod (`search`, `page_not_found`, `archives`, `tags`, `categories`, `posted_on`, `continue_reading`, `min_read`, `newer`, `older`, `page`, `of`, `read_more`, `translations` и др.) для полноты — отсутствующий ключ Hugo просто не находит и ничего не ломает (fallback).
|
|
||||||
- **Структура строки**: `[ключ]\nother = "перевод"` — Hugo i18n-формат (other — для языков без gender/plural).
|
|
||||||
- **Проверка**: `make build` + grep в public/ на русские строки («Читать далее», «Теги», «Содержание»).
|
|
||||||
|
|
||||||
## Risks / Trade-offs
|
|
||||||
|
|
||||||
- [Перевод строки, которую тема не использует] → Не навредит: Hugo игнорирует неиспользуемые ключи; лишние ключи просто расширяют словарь (fallback при будущих фичах темы).
|
|
||||||
- [Ключ использован темой, но перевод терминологически неточен] → Быстрый фикс: правим одну строку в ru.toml; git-история позволит откатить.
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
## Why
|
|
||||||
|
|
||||||
Сайт dedinit.ru — русскоязычный блог (язык ru-ru), но в теме rDedInit (форк PaperMod) отсутствует файл `i18n/ru.toml`, из-за чего интерфейсные строки (кнопки, навигация, время чтения) показываются на английском по умолчанию. Это выглядит неаккуратно для русскоязычной аудитории.
|
|
||||||
|
|
||||||
## What Changes
|
|
||||||
|
|
||||||
- Создать `i18n/ru.toml` с русскими переводами всех UI-ключей темы rDedInit (PaperMod).
|
|
||||||
- Перевести строки: Home → «Главная», Read time → «Время чтения», Words → «слов», Table of Contents → «Содержание», Code copy → «Копировать код», Code copied → «Скопировано», Edit post → «Редактировать пост», Next → «Следующая», Prev → «Предыдущая», Translations → «Переводы», Search → «Поиск» (и др. стандартные ключи PaperMod).
|
|
||||||
- Поведение сайта, разметка, контент — не меняются; это чисто интернационализация UI.
|
|
||||||
|
|
||||||
## Capabilities
|
|
||||||
|
|
||||||
### New Capabilities
|
|
||||||
- `i18n`: русская локализация интерфейса темы (ключи-переводы UI). Новая способность проекта «русская локализация UI».
|
|
||||||
|
|
||||||
### Modified Capabilities
|
|
||||||
Отсутствуют — существующих spec в `openspec/specs/` не затронуто (site-rss не меняется).
|
|
||||||
|
|
||||||
## Impact
|
|
||||||
|
|
||||||
- Затронутые файлы: `i18n/ru.toml` (новый).
|
|
||||||
- Не затрагивает: контент, шаблоны, деплой, RSS.
|
|
||||||
- Откат (rollback): удалить `i18n/ru.toml` (или `git revert`) — сайт вернётся к английским строкам по умолчанию, как сейчас.
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
## Purpose
|
|
||||||
|
|
||||||
Обеспечивает русскоязычный интерфейс сайта dedinit.ru: все UI-строки темы (навигация, время чтения, код, пагинация) отображаются на русском языке для аудитории ru-ru.
|
|
||||||
|
|
||||||
## ADDED Requirements
|
|
||||||
|
|
||||||
### Requirement: Русские UI-строки в i18n/ru.toml
|
|
||||||
Система SHALL предоставлять файл `i18n/ru.toml`, содержащий русские переводы всех ключей интерфейса, используемых темой rDedInit (PaperMod): `home`, `next_page`, `prev_page`, `read_time`, `words`, `toc`, `code_copy`, `code_copied`, `edit_post`, `translations`, `search`, `page_not_found` и стандартные ключи PaperMod.
|
|
||||||
|
|
||||||
#### Scenario: Главная страница
|
|
||||||
- **WHEN** пользователь открывает главную страницу сайта
|
|
||||||
- **THEN** строка навигации «Home» отображается как «Главная»
|
|
||||||
|
|
||||||
#### Scenario: Время чтения статьи
|
|
||||||
- **WHEN** пользователь открывает статью с временем чтения
|
|
||||||
- **THEN** строка «X min read» отображается как «X мин чтения» (или эквивалент на русском)
|
|
||||||
|
|
||||||
#### Scenario: Оглавление
|
|
||||||
- **WHEN** статья содержит оглавление
|
|
||||||
- **THEN** заголовок оглавления отображается как «Содержание», а не «Table of Contents»
|
|
||||||
|
|
||||||
#### Scenario: Кнопки копирования кода
|
|
||||||
- **WHEN** пользователь наводит на блок кода
|
|
||||||
- **THEN** подсказка отображается как «Копировать код»; после копирования — «Скопировано»
|
|
||||||
|
|
||||||
#### Scenario: Пагинация
|
|
||||||
- **WHEN** пользователь видит пагинацию списка постов
|
|
||||||
- **THEN** ссылки отображаются как «Следующая» / «Предыдущая», а не «Next» / «Prev»
|
|
||||||
|
|
||||||
#### Scenario: Отсутствующий ключ
|
|
||||||
- **WHEN** в теме используется ключ, отсутствующий в `i18n/ru.toml`
|
|
||||||
- **THEN** сайт продолжает работать, отображая ключ по умолчанию из темы (Hugo fallback) — поведение не ломается
|
|
||||||
|
|
||||||
### Requirement: Фолбэк на английский при отсутствии перевода
|
|
||||||
Система SHALL сохранять работоспособность сайта при отсутствии какого-либо ключа: Hugo отображает строку по умолчанию темы (английскую), сайт не падает и не ломает разметку.
|
|
||||||
|
|
||||||
#### Scenario: Нет ключа в ru.toml
|
|
||||||
- **WHEN** в ru.toml отсутствует конкретный ключ
|
|
||||||
- **THEN** Hugo использует значение по умолчанию из темы (английское) и сборка проходит без ошибок
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
## 1. Локализация
|
|
||||||
|
|
||||||
- [ ] 1.1 Создать каталог `i18n/` и файл `i18n/ru.toml` с переводами всех ключей темы (home, next_page, prev_page, read_time, words, toc, code_copy, code_copied, edit_post, translations) и стандартных ключей PaperMod (search, page_not_found, archives, tags, categories, posted_on, continue_reading, min_read, newer, older, read_more и др.) — проверить `ls i18n/ru.toml`
|
|
||||||
- [ ] 1.2 Собрать сайт: `make build` — сборка проходит без ошибок (exit 0)
|
|
||||||
|
|
||||||
## 2. Проверка
|
|
||||||
|
|
||||||
- [ ] 2.1 Проверить в собранном public/ русские строки: `grep -r "Читать далее" public/ | head` и `grep -r "Содержание" public/ | head` — обе строки присутствуют в HTML
|
|
||||||
- [ ] 2.2 Проверить пагинацию: `grep -r "Следующая" public/ | head` и `grep -r "Предыдущая" public/ | head` — присутствуют
|
|
||||||
- [ ] 2.3 Закоммитить `i18n/ru.toml` и запушить в gitverse (origin main)
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# archive-by-year-month Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Страница «Архив» (/posts/) блога dedinit.ru отображает все посты,
|
||||||
|
сгруппированные по годам и месяцам (как штатный архив Hugo), вместо
|
||||||
|
плоского списка с пагинацией.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Группировка постов по годам и месяцам
|
||||||
|
Система SHALL отображать на странице /posts/ список всех постов,
|
||||||
|
сгруппированный по годам публикации, и внутри каждого года — по месяцам
|
||||||
|
публикации. Заголовки содержат год/месяц и количество постов в группе.
|
||||||
|
|
||||||
|
#### Scenario: Страница /posts/ показывает годы
|
||||||
|
- **WHEN** пользователь открывает страницу /posts/
|
||||||
|
- **THEN** страница содержит заголовки годов (например «2026», «2023»), под каждым — посты этого года
|
||||||
|
- **AND** возле каждого года отображается количество постов
|
||||||
|
|
||||||
|
#### Scenario: Внутри года посты сгруппированы по месяцам
|
||||||
|
- **WHEN** пользователь открывает страницу /posts/
|
||||||
|
- **THEN** внутри каждого года посты сгруппированы по месяцам публикации (например «January», «September»)
|
||||||
|
- **AND** возле каждого месяца отображается количество постов
|
||||||
|
|
||||||
|
#### Scenario: Каждый пост — ссылка на свою страницу
|
||||||
|
- **WHEN** пользователь видит запись в архиве
|
||||||
|
- **THEN** заголовок поста является ссылкой на его страницу (permalink)
|
||||||
|
- **AND** под заголовком отображаются метаданные поста (дата и пр.)
|
||||||
|
|
||||||
|
### Requirement: Заголовок и описание страницы
|
||||||
|
Система SHALL отображать на странице /posts/ заголовок «Архив» и описание
|
||||||
|
страницы (из frontmatter секции).
|
||||||
|
|
||||||
|
#### Scenario: Заголовок страницы
|
||||||
|
- **WHEN** пользователь открывает страницу /posts/
|
||||||
|
- **THEN** заголовок страницы — «Архив»
|
||||||
|
|
||||||
|
### Requirement: Сохранение остальной функциональности секции
|
||||||
|
Система SHALL сохранять работоспособность RSS-ленты секции /posts/ и URL
|
||||||
|
постов (permalinks не меняются) при переключении layout на архив.
|
||||||
|
|
||||||
|
#### Scenario: RSS секции не сломан
|
||||||
|
- **WHEN** пользователь запрашивает RSS-ленту секции /posts/ (index.xml)
|
||||||
|
- **THEN** лента валидна и содержит посты секции
|
||||||
|
|
||||||
|
#### Scenario: Permalinks постов не меняются
|
||||||
|
- **WHEN** пользователь открывает пост из архива
|
||||||
|
- **THEN** URL поста соответствует permalink /:year/:slug/ (как и раньше)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# series-and-menu Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Шапка и контент dedinit.ru: таксономия серий постов (series), переключатель
|
||||||
|
темы справа у меню, иконки соцсетей в верхнем меню.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Series taxonomy
|
||||||
|
|
||||||
|
Таксономия `series` позволяет группировать посты в серии и показывает на
|
||||||
|
странице поста модуль навигации по серии.
|
||||||
|
|
||||||
|
#### Scenario: Taxonomy enabled
|
||||||
|
- Given hugo.toml has `[taxonomies] series = "series"`
|
||||||
|
- When site is built
|
||||||
|
- Then `/series/` term page exists and posts can be marked with `series`
|
||||||
|
|
||||||
|
#### Scenario: Series module on a post
|
||||||
|
- Given a post with `series: ["Test"]` and `series_order: 2` in front-matter
|
||||||
|
- When that post is rendered
|
||||||
|
- Then a "Серия" block is visible at the top of the content
|
||||||
|
- And it shows the series name, position («2 из N»)
|
||||||
|
- And it links to the previous article (if any) and next article (if any)
|
||||||
|
|
||||||
|
### Requirement: Theme toggle on the right
|
||||||
|
|
||||||
|
Переключатель темы (солнце/луна) отображается в правой части шапки, сразу
|
||||||
|
после пунктов меню.
|
||||||
|
|
||||||
|
#### Scenario: Toggle next to the menu
|
||||||
|
- Given the header markup with `.logo-switches` inside `.logo`
|
||||||
|
- When the page is rendered
|
||||||
|
- Then the theme toggle button is displayed at the right end of `.nav`,
|
||||||
|
after `#menu`
|
||||||
|
|
||||||
|
### Requirement: Social icons in the header
|
||||||
|
|
||||||
|
Иконки соцсетей из `[params.socialIcons]` отображаются в верхнем меню рядом
|
||||||
|
с переключателем темы.
|
||||||
|
|
||||||
|
#### Scenario: Icons rendered from params
|
||||||
|
- Given `[params.socialIcons]` with github and telegram entries
|
||||||
|
- When the page is rendered
|
||||||
|
- Then links with SVG icons appear in the header nav, next to the menu
|
||||||
+128
-26
@@ -1,14 +1,29 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Deploy public/ -> /dedinit.ru on Jino (SFTP-only, password auth).
|
"""Deploy public/ -> /dedinit.ru on Jino (SFTP-only, password auth).
|
||||||
|
|
||||||
Используется командой `make deploy` (единственный способ деплоя сайта).
|
Единственный способ деплоя сайта (Gitea Actions / локально).
|
||||||
Jino-аккаунт kpa39l_dedinit — SFTP-only: удалённый exec (rsync/ssh) запрещён,
|
Jino-аккаунт kpa39l_dedinit — SFTP-only: удалённый exec (rsync/ssh) запрещён,
|
||||||
поэтому заливаем через SFTP с рекурсивным mirror и --delete (как rsync).
|
поэтому mirror-заливка через SFTP с удалением лишнего (как rsync --delete).
|
||||||
Пароль — из env SSHPASS (см. README.md / docs/DEPLOY_INSTRUCTIONS.md).
|
Пароль — из env SSHPASS (см. README.md / docs/DEPLOY_INSTRUCTIONS.md).
|
||||||
|
|
||||||
|
Инкрементально: файл пропускается, если на сервере уже есть файл того же
|
||||||
|
размера И с тем же SHA1-хешем (точно). В CI (Gitea Actions) mtime после
|
||||||
|
checkout всегда свежий, поэтому mtime НЕ используется для сравнения —
|
||||||
|
только размер + содержимое. Это гарантирует, что тяжёлые неизменные
|
||||||
|
файлы (PDF/M4V) не переливаются повторно.
|
||||||
|
|
||||||
|
Защита от зависаний:
|
||||||
|
- Transport.set_keepalive(15) — пинги каждые 15с; при мёртвом соединении
|
||||||
|
канал падает сам.
|
||||||
|
- Таймаут на канал SFTP: channel.settimeout(120) — операция дольше 120с
|
||||||
|
прерывается с ошибкой (а не висит вечно).
|
||||||
|
- Ретраи: put/remove при обрыве переподключаются и повторяют операцию
|
||||||
|
(до 3 попыток).
|
||||||
"""
|
"""
|
||||||
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import stat as stmod
|
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import paramiko
|
import paramiko
|
||||||
@@ -24,7 +39,7 @@ LOCAL = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "public")
|
|||||||
REMOTE = os.environ.get("DEDINIT_REMOTE", "/dedinit.ru")
|
REMOTE = os.environ.get("DEDINIT_REMOTE", "/dedinit.ru")
|
||||||
DRYRUN = "--dry-run" in sys.argv
|
DRYRUN = "--dry-run" in sys.argv
|
||||||
|
|
||||||
if not PASS:
|
if not PASS and not DRYRUN:
|
||||||
print("ОШИБКА: переменная SSHPASS (пароль SFTP) не задана. См. README.md / docs/DEPLOY_INSTRUCTIONS.md", file=sys.stderr)
|
print("ОШИБКА: переменная SSHPASS (пароль SFTP) не задана. См. README.md / docs/DEPLOY_INSTRUCTIONS.md", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@@ -33,9 +48,31 @@ if not os.path.isdir(LOCAL):
|
|||||||
print(f"ОШИБКА: нет каталога {LOCAL}. Сначала: make build", file=sys.stderr)
|
print(f"ОШИБКА: нет каталога {LOCAL}. Сначала: make build", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
t = paramiko.Transport((HOST, PORT))
|
|
||||||
t.connect(username=USER, password=PASS)
|
def connect():
|
||||||
sftp = paramiko.SFTPClient.from_transport(t)
|
t = paramiko.Transport((HOST, PORT))
|
||||||
|
t.banner_timeout = 30
|
||||||
|
t.set_keepalive(15)
|
||||||
|
t.connect(username=USER, password=PASS)
|
||||||
|
sftp = paramiko.SFTPClient.from_transport(t)
|
||||||
|
ch = sftp.get_channel()
|
||||||
|
ch.settimeout(120)
|
||||||
|
return t, sftp
|
||||||
|
|
||||||
|
|
||||||
|
def disconnect(t, sftp):
|
||||||
|
try:
|
||||||
|
sftp.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
t.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
t = None
|
||||||
|
sftp = None
|
||||||
|
|
||||||
|
|
||||||
def rlist(path):
|
def rlist(path):
|
||||||
@@ -47,13 +84,74 @@ def rlist(path):
|
|||||||
for e in entries:
|
for e in entries:
|
||||||
full = os.path.join(path, e.filename)
|
full = os.path.join(path, e.filename)
|
||||||
rel = os.path.relpath(full, REMOTE)
|
rel = os.path.relpath(full, REMOTE)
|
||||||
isdir = stmod.S_ISDIR(e.st_mode)
|
isdir = e.st_mode and (e.st_mode & 0o170000) == 0o040000
|
||||||
out.append((rel, isdir))
|
out.append((rel, isdir))
|
||||||
if isdir:
|
if isdir:
|
||||||
out.extend(rlist(full))
|
out.extend(rlist(full))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def local_sha1(lpath):
|
||||||
|
h = hashlib.sha1()
|
||||||
|
with open(lpath, "rb") as f:
|
||||||
|
while True:
|
||||||
|
chunk = f.read(1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def remote_sha1(rpath):
|
||||||
|
"""SHA1 содержимого удалённого файла через SFTP (поток, без временных копий)."""
|
||||||
|
h = hashlib.sha1()
|
||||||
|
with sftp.open(rpath, "rb") as rf:
|
||||||
|
while True:
|
||||||
|
chunk = rf.read(1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def same_file(lpath, rstat):
|
||||||
|
"""True, если удалённый файл совпадает с локальным (размер + SHA1).
|
||||||
|
|
||||||
|
mtime НЕ используется (в CI после checkout он всегда свежий).
|
||||||
|
Для каждого файла: если размеры разные — льём сразу; если равны —
|
||||||
|
сравниваем SHA1 содержимого (медленно для крупных, но надёжно и
|
||||||
|
позволяет пропускать неизменные PDF/M4V).
|
||||||
|
"""
|
||||||
|
lsize = os.path.getsize(lpath)
|
||||||
|
if rstat.st_size != lsize:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return local_sha1(lpath) == remote_sha1(rpath)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def with_retry(fn, *args, retries=3, desc=""):
|
||||||
|
global t, sftp
|
||||||
|
for attempt in range(1, retries + 1):
|
||||||
|
try:
|
||||||
|
return fn(*args)
|
||||||
|
except Exception as e:
|
||||||
|
if attempt == retries:
|
||||||
|
print(f" ! {desc}: {e} (после {retries} попыток)", file=sys.stderr)
|
||||||
|
raise
|
||||||
|
print(f" ! {desc}: {e} — переподключение ({attempt}/{retries})", file=sys.stderr)
|
||||||
|
disconnect(t, sftp)
|
||||||
|
time.sleep(2)
|
||||||
|
t, sftp = connect()
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
t, sftp = connect()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ОШИБКА: не удалось подключиться к {HOST}:{PORT}: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
print(f"Копирую {LOCAL} -> sftp://{USER}@{HOST}:{PORT}{REMOTE}/"
|
print(f"Копирую {LOCAL} -> sftp://{USER}@{HOST}:{PORT}{REMOTE}/"
|
||||||
+ (" (DRY-RUN)" if DRYRUN else ""))
|
+ (" (DRY-RUN)" if DRYRUN else ""))
|
||||||
|
|
||||||
@@ -62,14 +160,11 @@ for root, dirs, files in os.walk(LOCAL):
|
|||||||
for f in files:
|
for f in files:
|
||||||
local_files.append(os.path.relpath(os.path.join(root, f), LOCAL))
|
local_files.append(os.path.relpath(os.path.join(root, f), LOCAL))
|
||||||
|
|
||||||
if not DRYRUN:
|
remote_entries = rlist(REMOTE) if not DRYRUN else []
|
||||||
remote_entries = rlist(REMOTE)
|
remote_files = {r for r, d in remote_entries if not d}
|
||||||
remote_files = {r for r, d in remote_entries if not d}
|
|
||||||
|
|
||||||
|
uploaded = skipped = 0
|
||||||
for rel in sorted(local_files):
|
for rel in sorted(local_files):
|
||||||
if DRYRUN:
|
|
||||||
print(" +", rel)
|
|
||||||
continue
|
|
||||||
lpath = os.path.join(LOCAL, rel)
|
lpath = os.path.join(LOCAL, rel)
|
||||||
rpath = os.path.join(REMOTE, rel)
|
rpath = os.path.join(REMOTE, rel)
|
||||||
rdir = os.path.dirname(rpath)
|
rdir = os.path.dirname(rpath)
|
||||||
@@ -85,18 +180,25 @@ for rel in sorted(local_files):
|
|||||||
sftp.mkdir(cur)
|
sftp.mkdir(cur)
|
||||||
except IOError:
|
except IOError:
|
||||||
pass
|
pass
|
||||||
sftp.put(lpath, rpath)
|
try:
|
||||||
print(" +", rel)
|
rstat = sftp.stat(rpath)
|
||||||
|
except IOError:
|
||||||
|
rstat = None
|
||||||
|
if rstat is not None and same_file(lpath, rstat):
|
||||||
|
skipped += 1
|
||||||
|
print(f" = {rel}")
|
||||||
|
continue
|
||||||
|
if not DRYRUN:
|
||||||
|
with_retry(sftp.put, lpath, rpath, desc=f"put {rel}")
|
||||||
|
uploaded += 1
|
||||||
|
print(f" + {rel}")
|
||||||
|
|
||||||
if not DRYRUN:
|
if not DRYRUN:
|
||||||
to_delete = remote_files - set(local_files)
|
to_delete = sorted(remote_files - set(local_files))
|
||||||
for rel in sorted(to_delete):
|
for rel in to_delete:
|
||||||
try:
|
with_retry(sftp.remove, os.path.join(REMOTE, rel), desc=f"remove {rel}")
|
||||||
sftp.remove(os.path.join(REMOTE, rel))
|
print(f" - {rel}")
|
||||||
print(" -", rel)
|
|
||||||
except Exception as e:
|
|
||||||
print(" !", rel, e)
|
|
||||||
|
|
||||||
sftp.close()
|
disconnect(t, sftp)
|
||||||
t.close()
|
print(f"Готово: {uploaded} залито, {skipped} пропущено."
|
||||||
print("Готово." if not DRYRUN else "DRY-RUN завершён: показаны изменения без заливки.")
|
if not DRYRUN else f"DRY-RUN завершён: будет залито {uploaded}, пропущено {skipped}.")
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
open sftp://kpa39l_dedinit:-Zp4ep747t25@kpa39l.myjino.ru:2222/
|
open sftp://kpa39l_dedinit:PASSWORD@kpa39l.myjino.ru:2222/
|
||||||
synchronize remote public/ /dedinit.ru/ -delete -transfer=binary
|
synchronize remote public/ /dedinit.ru/ -delete -transfer=binary
|
||||||
close
|
close
|
||||||
exit
|
exit
|
||||||
Reference in New Issue
Block a user