ProfileWebNORM
README.md
normno.com — portfolio website
A production portfolio website with a liquid-glass aesthetic: animated blob backgrounds,
backdrop blur with a glass effect, and a ripple effect on clickable elements. Dark and light
themes switch automatically based on the device’s theme (prefers-color-scheme).
The navigation bar features a signature smiley face; clicking it triggers a blinking animation (Easter egg).
Stack: Astro + Svelte islands · Node 22 · SQLite (Drizzle ORM) · Caddy · Docker Compose. Designed for a low-spec VPS: everything is as static and cached as possible, with a single process; the site never accesses GitHub or Telegram at the time of a user’s request.
Features
- Homepage — “About Me” section (text in Markdown and links are edited in the admin panel, photos are uploaded there as well), minimalist social media buttons, 3 projects with the most recent changes (based on the date of the last GitHub push, not the release date: so you can see where work is happening right now), a Spotify widget (current track; if nothing is playing—the last track played), the last 3 posts, and an activity heatmap with a GitHub/Claude toggle ✨.
- Projects—All / Hard / Agents tabs (“All” is open by default),
search and sorting (new releases, downloads, stars; the default option
is set in the admin panel). Card: cover image, title, description, stars, total
downloads. Project page: “Download latest version” and “Open on
GitHub” buttons (the link to the repository is always visible, even when there’s nothing to download),
rendered README, releases with dates and download counts for each asset,
list of open issues. README images fit within the width of
the column while maintaining their aspect ratio: GitHub adds
width/height, and without recalculating the height, a wide screenshot would be stretched vertically. In the admin panel, the categories are still called “Hard” and “Vibe”—the renaming was done only in the public section. - The download menu—“Download the latest version”—and the button next to each release expand to reveal a list of files: a platform icon based on the file extension (Windows, macOS, Linux, Android, iOS, Java, archive), size, and number of downloads. The file for the visitor’s OS is moved to the top and marked “for your OS.” If there’s only one file, it’s a regular link.
- Posts—Markdown posts with images from the admin panel and those imported from the Telegram channel (along with photos and albums), source badge, sorted by date. The list loads in batches as you scroll. Site posts are also published to the channel (rich-markdown, Bot API 10.1); edits are synchronized in both directions without duplicates; synchronized posts have a blue airplane icon and a link to the channel post. Details: docs/TELEGRAM.md.
- Post previews—the first image in a post becomes the cover image for the card
(on the home page and in the list of posts) and
og:imageits page. A WebP thumbnail 640 px wide is generated once—when uploaded from the admin panel or imported from Telegram—so the list uses tens of kilobytes instead of the megabyte-sized originals; while the original image is still included in the post body. Space for the image is reserved based on its aspect ratio, so the list doesn’t jump around when loading. External images () are not included in the cover image. - Project cover images use the GitHub og-image by default; in the admin panel, you can select
an image from the repository’s README (only the URL is stored) or upload your own
(only in this case is the file stored on the server, in
data/uploads). They are displayed via/media/repo/<id>: both the local and external images are resized to the required width (160 px for the homepage thumbnail, 1000 px for the card) and cached WebP copy on disk—the README contains megabyte-sized screenshots, while the card shows a loading bar. The site makes a single request to an external host for each image. - Lists without extra work—the page serves the first batch of cards as ready-to-use
HTML (projects—9, posts—8), and the rest are loaded as you scroll:
posts come from
/api/publications, and projects are retrieved from a compact JSON file embedded in the page (tabs, search, and sorting also work there— without server requests). Without JavaScript, the link?all=1opens the entire list, and search engines can see all entries in JSON-LD, andsitemap.xml. - Speed—there isn’t a single external JS or CSS file on public pages:
styles are inlined in HTML, and the Spotify widget takes just a dozen lines instead of
Svelte runtime. The lists read only the columns from SQLite that are needed
for the card (README and post bodies aren’t loaded at all), and blocks below the first
screen are marked up using
content-visibility, the avatar is served as a scaled-down WebP copy withpreload, and background animation is enabled only where it doesn’t interfere with rendering. The result on a throttled mobile profile in Lighthouse: 100/100/100. - RSS — a feed of posts on
/rss.xml(+ auto-detection in<head>). - Backups via Telegram—every day at 4:30 a.m., the bot sends an archive of the database and uploaded
files to a private chat (
TELEGRAM_BACKUP_CHAT_ID), plus a manual backup button in the admin panel. - Two language versions—Russian on “clean” URLs, English at
/en(/en/publications/12). The browser’s language does not override the page’s language: following Google’s recommendation, the site does not redirect based onAccept-Language, but displays a banner at the top reading “This page is also available in English” with a link (the banner can be closed— it won’t appear again). Everyone—whether a guest or a bot—receives exactly the URL they requested. The language selection is stored in a cookie: after that, the “clean” URL redirects to the selected version. The round button in the corner switches the language on the same page and works without JavaScript. - Automatic translation via API—the interface is translated manually (no quota is used), while content is translated using Google Cloud Translation (or DeepL / Azure, as desired) with a cache in SQLite. New posts are translated immediately upon publication; older pages are translated on demand upon the first visit. Editing a post (in the admin panel or on the Telegram channel) marks the translation as outdated based on the source hash and triggers a new translation; until it’s ready, the previous English text is displayed instead of the Russian version. The README, which is already in English, is identified based on the proportion of Cyrillic characters and is not translated at all. Nothing runs locally: for 2 GB of RAM, this is the only viable option. Details: docs/TRANSLATE.md.
- Data for AI agents (WebMCP)—pages are declared by tools via
navigator.modelContext: the agent in the browser callslist_projects,get_project,list_publications,get_publication,search_site,get_site_overviewandshow_projectsinstead of parsing the layout. The same data is available to regular HTTP clients in/api/agent/*.json, and/llms.txtprovides a brief overview of the site with links to them. The WebMCP script loads dynamically and only where the browser supports this API—for a regular visitor, it doesn’t cost a single byte. - SEO — canonical and
hreflangon every page,sitemap.xmlwith both language versions, dynamicrobots.txt(on the admin host—completely prohibited), Open Graph and Twitter Card, JSON-LD (Person,WebSite,BlogPosting,SoftwareSourceCode, breadcrumbs), RSS in two languages, and IndexNow ping to Bing/Yandex upon publication. Checklist for moving to a new domain: docs/SEO.md. - Data sync—an in-app cron job (set to run every 30 minutes by default) fetches GitHub
(repos, releases + download_count, stargazers, issues, README, contribution calendar,
commits involving Claude) and Telegram channel posts and writes them to SQLite. The README and posts
are rendered
markdown-it+sanitize-htmlduring the sync phase—the site never makes calls to external APIs when a user submits a request. - Admin panel—tabs “GitHub Projects” (visibility, Hard/Vibe, covers,
default sorting, manual sync), “About Me and Links” (Markdown with preview, photo, connection
), “Posts” (editor with preview and photos—you can select a file in the dialog,
paste a screenshot from the clipboard (Ctrl/⌘+V), or drag and drop multiple files at once
into the input field; a Markdown link is inserted at the cursor position—send to Telegram, import,
bulk deletion via checkboxes, bot diagnostics, manual backup), and “Translations”
(active provider, monthly character limit usage with a progress bar,
comparison of free service limits, API error analysis, translation status
for each post, and manual translation). Uploaded files are stored in
data/uploads, the same volume as the database.
Admin panel security: a separate subdomain
The admin panel is hidden from site visitors:
/admin*returns a 404 on the main domain—and in the application (middleware checksHost), and in Caddy (requests to/admin*the main domain are not proxied at all);- the admin panel can only be accessed from a subdomain
ADMIN_HOST(for exampleadmin.example.com) and is protected by Basic Auth (ADMIN_USER/ADMIN_PASSthere.env); - there is no link to the admin panel in the public navigation,
robots.txtandX-Robots-Tagindexing is disabled.
Local development
npm install
cp .env.example .env # заполнить минимум ADMIN_PASS; токены — по мере надобности
npm run dev
- Website: http://localhost:4321
- Admin panel: http://admin.localhost:4321/admin (in dev
ADMIN_HOST=admin.localhost; browsers will resolve it themselves*.localhostto 127.0.0.1)
The first sync with GitHub runs automatically a few seconds after startup (or by clicking the “Sync with GitHub” button in the admin panel).
Deployment to a VPS (Docker Compose)
Complete guide — docs/DEPLOY.md: setting up integrations, backups and restoration, updates, troubleshooting common issues. In short:
-
Point your DNS A records
example.comandadmin.example.comto the server’s IP address. -
Install Docker (
curl -fsSL https://get.docker.com | sh), open ports 80 and 443. -
Deploy:
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 will automatically issue Let's Encrypt HTTPS certificates for both domains.
-
Update:
git pull && docker compose up -d --build(schema migrations are applied automatically; data is preserved).
The entire site’s status is stored in ./data: site.db (SQLite) and uploads/ (profile photos,
cover images, post images). A backup is an archive of this directory; when configured,
TELEGRAM_BACKUP_CHAT_ID the bot sends it automatically every day.
Environment variables
| Variable | Description |
|---|---|
SITE_URL |
Public website address (for links) |
SITE_DOMAIN / ADMIN_DOMAIN |
Domains for Caddy |
ALIAS_DOMAINS |
Additional domains separated by spaces: each with its own certificate, 301 redirect to SITE_DOMAIN (DEPLOY 2.1) |
ADMIN_HOST |
Admin panel host; on other hosts /admin → 404 |
ADMIN_USER / ADMIN_PASS |
Basic Auth for the admin panel (admin panel is disabled without a password) |
GITHUB_USERNAME |
Whose repositories to display |
GITHUB_TOKEN |
Classic PAT with scope public_repo + read:user: API limits and heat map |
SPOTIFY_CLIENT_ID / SPOTIFY_CLIENT_SECRET |
Spotify app (the widget is hidden without them) |
SPOTIFY_REFRESH_TOKEN |
Optional: manually obtained token (connection via the admin panel takes priority) |
SPOTIFY_REDIRECT_URI |
Optional: override the redirect URI (for local development) |
TELEGRAM_BOT_TOKEN |
Bot for importing and publishing channel posts (the bot is the channel admin; see docs/TELEGRAM.md) |
TELEGRAM_CHANNEL |
@username channel (or -100…); required for publishing website posts to the channel |
TELEGRAM_BACKUP_CHAT_ID |
Personal chat_id for daily backups (empty = disabled) |
TRANSLATE_PROVIDER |
Auto-translation provider: google / deepl / azure / none (empty — the first one with a key) |
GOOGLE_TRANSLATE_API_KEY |
Google Cloud Translation API key (500,000 characters/month free) |
DEEPL_API_KEY / AZURE_TRANSLATOR_KEY |
Alternative translation services, see docs/TRANSLATE.md |
TRANSLATE_MONTHLY_LIMIT |
Your monthly character limit (0 — provider's free limit) |
INDEXNOW_KEY |
IndexNow key: the site automatically notifies Bing and Yandex about new posts |
GOOGLE_SITE_VERIFICATION / YANDEX_VERIFICATION |
content= Meta tags for rights confirmation in Search Console and Webmaster Tools |
DB_PATH |
Path to the SQLite file |
SYNC_INTERVAL_MIN |
Sync interval, in minutes (default: 30) |
TZ |
Container time zone—the daily backup time depends on this (4:30 AM) |
How to connect Spotify
Everything is done via a browser (including on a phone); no terminal is required:
- Create an app at https://developer.spotify.com/dashboard.
- In its settings, add a Redirect URI—the admin panel URL:
https://admin.example.com/admin/spotify/callback(replace this with your ownADMIN_DOMAIN; the exact value is shown in the admin panel). - Enter
SPOTIFY_CLIENT_IDandSPOTIFY_CLIENT_SECRETin.env, then restart:docker compose up -d --build. -
Open the admin panel → “About Me and Links” tab → “♫ Connect Spotify”
→ confirm access. The refresh token will be saved to the database automatically,
SPOTIFY_REFRESH_TOKENyou.envyou don’t need to enter it.
Spotify only accepts HTTPS addresses and loopback (127.0.0.1) — so
the redirect leads to the admin panel domain, where HTTPS is already configured via Caddy.
For local development, set SPOTIFY_REDIRECT_URI=http://127.0.0.1:4321/admin/spotify/callback
and add the same address to the Spotify app.
Manual method (if you need a token in .env)
Open it in a browser (replace with your own client_id and redirect_uri from the app),
authorize access, and copy code from the address bar—the page may
display an error, but that’s normal:
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 from the response → to .env as SPOTIFY_REFRESH_TOKEN
(the value from .env takes precedence over the one received via the admin panel).
Structure
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)
Releases
No releases yet.
Open issues
No open issues 🎉