ProfileWebNORM
README.md
normno.com — сайт-портфолио
Production-сайт-портфолио с liquid-glass эстетикой: анимированные blob-фоны,
backdrop-blur стекло, ripple-эффект на кликабельных элементах. Тёмная и светлая
темы переключаются автоматически по теме устройства (prefers-color-scheme).
В навигации — фирменный смайл; клик по нему запускает анимацию моргания (пасхалка).
Стек: Astro + Svelte-островки · Node 22 · SQLite (Drizzle ORM) · Caddy · Docker Compose. Рассчитан на слабый VPS: всё максимально статично и кешировано, один процесс, сайт никогда не ходит в GitHub/Telegram в момент запроса пользователя.
Возможности
- Главная — блок «обо мне» (текст в Markdown и ссылки редактируются в админке, фото загружается там же), минималистичные кнопки соцсетей, 3 проекта с самыми свежими изменениями (по дате последнего пуша в GitHub, а не по дате релиза: так видно, где работа идёт прямо сейчас), виджет Spotify (текущий трек; если ничего не играет — последний прослушанный), последние 3 публикации и тепловая карта активности с переключателем GitHub / Claude ✨.
- Проекты — вкладки Все / Hard / Agents («Все» открыта по умолчанию),
поиск и сортировка (новые релизы, загрузки, звёзды; вариант по умолчанию
задаётся в админке). Карточка: обложка, название, описание, звёзды, суммарные
загрузки. Страница проекта: кнопки «Скачать последнюю версию» и «Открыть на
GitHub» (ссылка на репозиторий видна всегда, даже когда есть что скачать),
отрендеренный README, релизы с датами и загрузками каждого asset,
список открытых issues. Картинки README вписываются в ширину
колонки со своей пропорцией: GitHub проставляет в них
width/height, и без пересчёта высоты широкий скриншот вытягивался бы по вертикали. В админке категории по-прежнему называются Hard и Vibe — переименование сделано только в публичной части. - Меню загрузки — «Скачать последнюю версию» и кнопка у каждого релиза раскрывают список файлов: иконка платформы по расширению (Windows, macOS, Linux, Android, iOS, Java, архив), размер и число загрузок. Файл под ОС посетителя поднимается наверх и помечается «для вашей ОС». Если файл один — обычная ссылка.
- Публикации — markdown-посты с картинками из админки и импортированные из Telegram-канала (вместе с фото и альбомами), бейдж источника, сортировка по дате. Список подгружается пачками при прокрутке. Посты сайта публикуются и в канал (rich-markdown, Bot API 10.1), правки синхронизируются в обе стороны без дублей; у синхронизированных постов — синий самолёт и ссылка на пост канала. Подробно: docs/TELEGRAM.md.
- Превью публикаций — первая картинка поста становится обложкой карточки
(на главной и в списке публикаций) и
og:imageего страницы. WebP-миниатюра шириной 640 px делается один раз — при загрузке из админки или импорте из Telegram, — поэтому список отдаёт десятки килобайт вместо мегабайтных оригиналов; в тело поста при этом по-прежнему идёт оригинал. Место под картинку зарезервировано соотношением сторон, так что список не дёргается при загрузке. Внешние картинки () в обложку не берутся. - Обложки проектов — по умолчанию og-image GitHub; в админке можно выбрать
картинку из README репозитория (хранится только URL) или загрузить свою
(только в этом случае файл лежит на сервере, в
data/uploads). Показываются они через/media/repo/<id>: и своя, и внешняя картинка приводятся к нужной ширине (160 px для миниатюры на главной, 1000 px для карточки) и кешируются WebP-копией на диске — в README лежат скриншоты по мегабайту, а в карточке видно полоску. К чужому хосту сайт ходит один раз за картинку. - Списки без лишней работы — страница отдаёт первую пачку карточек готовым
HTML (проекты — 9, публикации — 8), остальные дорисовываются при прокрутке:
публикации приходят из
/api/publications, проекты берутся из компактного JSON, вложенного в страницу (там же работают вкладки, поиск и сортировка — без запросов к серверу). Без JavaScript ссылка?all=1открывает список целиком, а поисковикам все позиции видны в JSON-LD иsitemap.xml. - Скорость — на публичных страницах нет ни одного внешнего файла JS и CSS:
стили инлайнятся в HTML, виджет Spotify обходится десятком строк вместо
рантайма Svelte. Списки читают из SQLite только те колонки, что нужны
карточке (README и тела постов не поднимаются вовсе), блоки ниже первого
экрана размечаются через
content-visibility, аватар отдаётся уменьшенной WebP-копией сpreload, а анимация фона включена только там, где не мешает отрисовке. Итог на throttled-мобильном профиле Lighthouse: 100/100/100. - RSS — лента публикаций на
/rss.xml(+ автообнаружение в<head>). - Бэкапы в Telegram — ежедневно в 04:30 бот присылает архив БД и загруженных
файлов в личный чат (
TELEGRAM_BACKUP_CHAT_ID), плюс кнопка ручного бэкапа в админке. - Две языковые версии — русская на «чистых» адресах, английская под
/en(/en/publications/12). Язык браузера страницу не подменяет: по рекомендации Google сайт не редиректит поAccept-Language, а показывает вверху полосу «This page is also available in English» со ссылкой (полосу можно закрыть — больше не покажется). Каждый — и гость, и робот — получает ровно тот URL, что запросил. Выбор языка запоминается в куки: после него «чистый» адрес возвращает в выбранную версию. Круглая кнопка в углу переключает язык на той же странице и работает без JavaScript. - Автоперевод через API — интерфейс переведён вручную (лимит не тратится), контент — Google Cloud Translation (или DeepL / Azure на выбор) с кешем в SQLite. Новые посты переводятся сразу после публикации, старые страницы — лениво при первом заходе. Правка поста (в админке или в Telegram-канале) помечает перевод устаревшим по хешу исходника и запускает его заново, а до готовности показывается прошлый английский текст, а не русский. README, который и так на английском, определяется по доле кириллицы и не переводится вовсе. Локально ничего не крутится: для 2 ГБ ОЗУ это единственный рабочий вариант. Подробно: docs/TRANSLATE.md.
- Данные для ИИ-агентов (WebMCP) — страницы объявляют инструменты через
navigator.modelContext: агент в браузере вызываетlist_projects,get_project,list_publications,get_publication,search_site,get_site_overviewиshow_projectsвместо того, чтобы разбирать вёрстку. Те же данные доступны обычным HTTP-клиентам в/api/agent/*.json, а/llms.txtдаёт короткий обзор сайта со ссылками на них. Скрипт WebMCP подключается динамически и только там, где браузер даёт этот API, — обычному посетителю он не стоит ни байта. - SEO — canonical и
hreflangна каждой странице,sitemap.xmlс обеими языковыми версиями, динамическийrobots.txt(на админ-хосте — полный запрет), Open Graph и Twitter Card, JSON-LD (Person,WebSite,BlogPosting,SoftwareSourceCode, хлебные крошки), RSS на двух языках и IndexNow-пинг Bing/Яндекса при публикации. Чек-лист переезда на новый домен: docs/SEO.md. - Синк данных — cron внутри приложения (по умолчанию раз в 30 минут) тянет GitHub
(repos, releases + download_count, stargazers, issues, README, календарь контрибуций,
коммиты с участием Claude) и посты Telegram-канала и пишет в SQLite. README и посты
рендерятся
markdown-it+sanitize-htmlна этапе синка — сайт никогда не ходит во внешние API в момент запроса пользователя. - Админка — вкладки «Проекты GitHub» (видимость, Hard/Vibe, обложки, сортировка
по умолчанию, ручной синк), «Обо мне и ссылки» (markdown с превью, фото, подключение
Spotify), «Публикации» (редактор с превью и фото — файл можно выбрать в диалоге,
вставить скриншот из буфера обмена (Ctrl/⌘+V) или перетащить сразу несколько файлов
в поле ввода, markdown-ссылка встаёт в позицию курсора, — отправка в Telegram, импорт,
массовое удаление чекбоксами, диагностика бота, ручной бэкап) и «Переводы»
(активный провайдер, расход месячного лимита символов с прогресс-баром,
сравнение бесплатных лимитов сервисов, разбор ошибок API, статус перевода
каждого поста и ручной перевод). Загруженные файлы — в
data/uploads, том же volume, что и БД.
Безопасность админки: отдельный поддомен
Админка скрыта от посетителей сайта:
/admin*отвечает 404 на основном домене — и в приложении (middleware проверяетHost), и в Caddy (запросы к/admin*на основном домене вообще не проксируются);- админка открывается только с поддомена из
ADMIN_HOST(напримерadmin.example.com) и закрыта Basic Auth (ADMIN_USER/ADMIN_PASSиз.env); - в публичной навигации ссылки на админку нет,
robots.txtиX-Robots-Tagзапрещают индексацию.
Локальная разработка
npm install
cp .env.example .env # заполнить минимум ADMIN_PASS; токены — по мере надобности
npm run dev
- Сайт: http://localhost:4321
- Админка: http://admin.localhost:4321/admin (в dev
ADMIN_HOST=admin.localhost; браузеры сами резолвят*.localhostв 127.0.0.1)
Первый синк с GitHub запускается автоматически через несколько секунд после старта (или кнопкой «Синхронизировать с GitHub» в админке).
Деплой на VPS (Docker Compose)
Полное руководство — docs/DEPLOY.md: подключение интеграций, бэкапы и восстановление, обновление, разбор типичных проблем. Кратко:
-
Направьте DNS A-записи
example.comиadmin.example.comна IP сервера. -
Установите Docker (
curl -fsSL https://get.docker.com | sh), освободите порты 80/443. -
Разверните:
git clone https://github.com/NORMss/ProfileWebNORM.git cd ProfileWebNORM cp .env.example .env nano .env # SITE_DOMAIN, ADMIN_DOMAIN, ADMIN_HOST, ADMIN_PASS, TZ, токены docker compose up -d --buildCaddy сам выпустит HTTPS-сертификаты Let's Encrypt для обоих доменов.
-
Обновление:
git pull && docker compose up -d --build(миграции схемы применяются автоматически, данные сохраняются).
Всё состояние сайта — в ./data: site.db (SQLite) и uploads/ (фото профиля,
обложки, картинки постов). Бэкап = архив этого каталога; при заданном
TELEGRAM_BACKUP_CHAT_ID бот присылает его сам каждый день.
Переменные окружения
| Переменная | Описание |
|---|---|
SITE_URL |
Публичный адрес сайта (для ссылок) |
SITE_DOMAIN / ADMIN_DOMAIN |
Домены для Caddy |
ALIAS_DOMAINS |
Дополнительные домены через пробел: свой сертификат у каждого, 301 на SITE_DOMAIN (DEPLOY 2.1) |
ADMIN_HOST |
Хост админки; на других хостах /admin → 404 |
ADMIN_USER / ADMIN_PASS |
Basic Auth админки (без пароля админка отключена) |
GITHUB_USERNAME |
Чьи репозитории показывать |
GITHUB_TOKEN |
Classic PAT со scope public_repo + read:user: лимиты API и тепловая карта |
SPOTIFY_CLIENT_ID / SPOTIFY_CLIENT_SECRET |
Приложение Spotify (без них виджет скрыт) |
SPOTIFY_REFRESH_TOKEN |
Необязательно: токен, полученный вручную (приоритетнее подключения из админки) |
SPOTIFY_REDIRECT_URI |
Необязательно: переопределить redirect URI (для локальной разработки) |
TELEGRAM_BOT_TOKEN |
Бот для импорта и публикации постов канала (бот — админ канала, см. docs/TELEGRAM.md) |
TELEGRAM_CHANNEL |
@username канала (или -100…); нужен для публикации постов сайта в канал |
TELEGRAM_BACKUP_CHAT_ID |
Личный chat_id для ежедневных бэкапов (пусто — выключено) |
TRANSLATE_PROVIDER |
Провайдер автоперевода: google / deepl / azure / none (пусто — первый с ключом) |
GOOGLE_TRANSLATE_API_KEY |
Ключ Google Cloud Translation API (бесплатно 500 000 символов/мес) |
DEEPL_API_KEY / AZURE_TRANSLATOR_KEY |
Альтернативные переводчики, см. docs/TRANSLATE.md |
TRANSLATE_MONTHLY_LIMIT |
Свой потолок символов в месяц (0 — бесплатный лимит провайдера) |
INDEXNOW_KEY |
Ключ IndexNow: сайт сам сообщает Bing и Яндексу о новых постах |
GOOGLE_SITE_VERIFICATION / YANDEX_VERIFICATION |
content= мета-тегов подтверждения прав в Search Console и Вебмастере |
DB_PATH |
Путь к файлу SQLite |
SYNC_INTERVAL_MIN |
Период синка, минут (по умолчанию 30) |
TZ |
Часовой пояс контейнера — от него зависит время ежедневного бэкапа (04:30) |
Как подключить Spotify
Всё делается из браузера (в том числе с телефона), терминал не нужен:
- Создайте приложение на https://developer.spotify.com/dashboard.
- В его настройках добавьте Redirect URI — адрес админки:
https://admin.example.com/admin/spotify/callback(подставьте свойADMIN_DOMAIN; точное значение показано в админке). - Впишите
SPOTIFY_CLIENT_IDиSPOTIFY_CLIENT_SECRETв.env, перезапустите:docker compose up -d --build. - Откройте админку → вкладка «Обо мне и ссылки» → «♫ Подключить Spotify»
→ подтвердите доступ. Refresh token сохранится в БД автоматически,
SPOTIFY_REFRESH_TOKENв.envзаполнять не нужно.
Spotify принимает только HTTPS-адреса и loopback (127.0.0.1) — поэтому
redirect ведёт на домен админки, где уже настроен HTTPS от Caddy.
Для локальной разработки задайте SPOTIFY_REDIRECT_URI=http://127.0.0.1:4321/admin/spotify/callback
и добавьте тот же адрес в приложение Spotify.
Ручной способ (если нужен токен в .env)
Откройте в браузере (подставьте свой client_id и redirect_uri из приложения),
подтвердите доступ, скопируйте code из адресной строки — страница при этом
может показать ошибку, это нормально:
https://accounts.spotify.com/authorize?client_id=CLIENT_ID&response_type=code&redirect_uri=REDIRECT_URI&scope=user-read-currently-playing%20user-read-recently-played
curl -X POST https://accounts.spotify.com/api/token \
-u "CLIENT_ID:CLIENT_SECRET" \
-d grant_type=authorization_code -d code=CODE \
-d redirect_uri=REDIRECT_URI
refresh_token из ответа → в .env как SPOTIFY_REFRESH_TOKEN
(значение из .env имеет приоритет над полученным через админку).
Структура
src/
middleware.ts # host-gating админки + Basic Auth + CSRF + языковой
# роутинг (/en, выбор языка в куки) + запуск cron
lib/
config.ts # доступ к env
db/ # better-sqlite3 + Drizzle, DDL, миграции, дефолты
sync/ # github.ts (репозитории, контрибуции, Claude-коммиты),
# telegram.ts (импорт постов и фото), планировщик
telegram.ts # публикация и правка постов в канале (rich-markdown)
backup.ts # снапшот БД + uploads → архив в Telegram
spotify.ts # now-playing с кешем 30 с
markdown.ts # markdown-it + sanitize-html
images.ts # обложки постов: WebP-миниатюры (sharp), уменьшенные
# копии аватара и обложек проектов, backfill
posts.ts # производные поля поста: HTML, обложка, превью, хеш тела
cards.ts # шаблоны карточек списков (общие для сервера и браузера)
agent.ts # данные сайта для ИИ-агентов: /api/agent/*, WebMCP, llms.txt
i18n/ # языки, разбор Accept-Language, словарь строк интерфейса
translate/ # провайдеры API, кеш переводов в SQLite, учёт лимита
seo.ts # canonical, hreflang, Open Graph, JSON-LD
indexnow.ts # пинг Bing/Яндекса при публикации
pages/
index/projects/publications # публичные страницы
rss.xml.ts # RSS-лента (на языке версии сайта)
sitemap.xml.ts # карта сайта с hreflang-альтернативами
robots.txt.ts # robots: публичный хост и админ-хост по-разному
[key].txt.ts # файл-подтверждение ключа IndexNow
llms.txt.ts # обзор сайта для языковых моделей
api/now-playing.ts # JSON для виджета
api/publications.ts # следующая пачка карточек списка публикаций
api/agent/*.json.ts # машинные данные: обзор, проекты, посты, поиск
media/** # отдача загруженных изображений
admin/** # админка, её API и OAuth Spotify
components/ # Icon, Heatmap, SpotifyWidget, LangHint, admin/AdminApp
public/webmcp.js # инструменты WebMCP; грузится только при поддержке API
docs/DEPLOY.md # руководство по развертыванию
docs/TELEGRAM.md # бот: посты, синхронизация, бэкапы
docs/TRANSLATE.md # автоперевод: выбор API, лимиты, кеш, ошибки
docs/SEO.md # индексация, hreflang, данные для ИИ-агентов, переезд
docs/PERFORMANCE.md # скорость публичных страниц: что сделано и что осталось
deploy/Caddyfile # домены: сайт, админка, алиасы с 301
deploy/local/ # свои блоки Caddy для этого сервера (не в git)
Релизы
Релизов пока нет.
Открытые issues
Открытых issues нет 🎉