Compare commits

..

22 Commits

Author SHA1 Message Date
Dmitry Gusev
7dc431f78e docs: фиксация правил уникальности SEO title/description
All checks were successful
deploy / deploy (push) Successful in 1m10s
security / security (push) Successful in 3m25s
2026-05-30 02:25:09 +03:00
Dmitry Gusev
b40c377761 fix(seo): убрать дубли title и description
Some checks failed
deploy / deploy (push) Successful in 1m24s
security / security (push) Has been cancelled
- 6 pages (o-nas, nashi-druzya, 4 миры) получили свой description в frontmatter;
  раньше [slug].astro для типа page передавал description=undefined → fallback
  на SITE_DESCRIPTION даёт 6 одинаковых meta-description.
- [slug].astro: для постов в SEO-title подставляется год публикации (или
  полная дата, если есть второй пост с тем же title в том же году). Покрывает
  дубли «Внимание! Технические работы!» (×5) и «С 23 февраля!» (×2).
- index.astro: свой description для главной (вместо SITE_DESCRIPTION).
- category/[slug].astro: вычисляемый description с количеством публикаций и
  диапазоном лет — на случай если категории попадут в индексацию.
2026-05-30 02:22:33 +03:00
Dmitry Gusev
048cb673e0 feat(brand): актуальный фавикон по брендовому знаку
All checks were successful
deploy / deploy (push) Successful in 1m33s
security / security (push) Successful in 2m41s
Старый favicon.svg (стилизованная «A» от скаффолдинга) заменён на
оптимизированный для 16-32px вариант logo-mark: кривые-отражения +
точка на тёмном фоне, тот же градиент #b794ff→#6c4ed4.

- public/favicon.svg — новый 64×64 viewBox, утолщённые штрихи
- public/favicon.ico — 32×32 (sharp → PNG-in-ICO)
- public/apple-touch-icon.png — 180×180 для iOS
- scripts/build-favicon.mjs + npm run build:favicon
- BaseLayout: добавлены <link> на ico (fallback) и apple-touch-icon
2026-05-25 13:33:22 +03:00
Dmitry Gusev
23c8deebd2 chore(security): .gitignore + .gitleaks.toml защита от CMS-export leak
All checks were successful
deploy / deploy (push) Successful in 1m2s
security / security (push) Successful in 2m45s
Превентивная защита от случайной публикации content/logs, content/data, ghost.*.json (см. инцидент moovg_ru 2026-05-24).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 20:09:23 +03:00
Dmitry Gusev
2a539705e7 fix(security): npm audit fix + GitLeaks allowlist for indexnow.js
All checks were successful
deploy / deploy (push) Successful in 1m5s
security / security (push) Successful in 2m42s
- npm audit fix: устранены 5 vulnerabilities (где возможно без --force):
  - path-to-regexp <0.1.13 (ReDoS, HIGH)
  - nodemailer 6.x patch
  - qs 6.7.x DoS (transitively через body-parser + express)

- .gitleaks.toml: расширен allowlist для scripts/indexnow.js* и
  scripts/indexnow-ping.sh — содержат публичный IndexNow KEY, не секрет.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 19:11:10 +03:00
Dmitry Gusev
df3d3d32b8 fix(security): GitLeaks allowlist + Dockerfile DL4006 + npm audit в CI
Some checks failed
deploy / deploy (push) Has been cancelled
security / security (push) Has been cancelled
GitLeaks: 8 false-positives на vgrf_ru (IndexNow public key + legacy
WP plugin code) — добавлен .gitleaks.toml с allowlist:
- public/<32hex>.txt + корневой <32hex>.txt (IndexNow validation files)
- wp-content/** (legacy WordPress plugin code, не настоящие секреты)
- const KEY = '<32hex>' паттерн

Hadolint DL4006: добавлен SHELL pipefail в начале каждой stage.

npm audit: убран из Dockerfile (там кэшировался Docker layer'ом и
по факту не запускался при unchanged package-lock.json). Вынесен в
.gitea/workflows/security.yml как отдельный job — каждый push, реально.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 18:54:02 +03:00
Dmitry Gusev
f10a22bd25 feat(security): GitHub Actions security.yml (mirror coverage)
All checks were successful
deploy / deploy (push) Successful in 48s
security / security (push) Successful in 2m32s
Дополнительный security-сканирование на GitHub mirror: hadolint-action, gitleaks-action, semgrep, trivy-action. Запускается на push + PR + weekly cron (Mon 03:00 UTC).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 17:56:31 +03:00
Dmitry Gusev
6005ec32f7 fix(security): nativе binaries вместо docker run (Gitea fix)
Some checks failed
deploy / deploy (push) Has been cancelled
security / security (push) Has been cancelled
Gitea act_runner не имеет docker внутри workflow-контейнера —
"docker not found" маскировался через `|| true`, Layer C по факту
не работал. Заменено на:
- Hadolint v2.12.0 binary (curl + chmod)
- GitLeaks v8.21.2 (tarball)
- Semgrep через python3 venv + pip

Все три по-прежнему warning-only, но теперь реально сканируют.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 17:49:20 +03:00
Dmitry Gusev
7455aadb28 docs(security): SECURITY.md с описанием Layer A+B+C stack
All checks were successful
deploy / deploy (push) Successful in 1m7s
security / security (push) Successful in 7s
Описывает все автоматические проверки: Trivy, npm audit (A), Nuclei (B), Hadolint+GitLeaks+Semgrep (C). Email для приватных репортов: admin@hhivp.com.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 17:04:39 +03:00
Dmitry Gusev
7f891c4759 feat(security): Hadolint + GitLeaks + Semgrep workflow (Layer C)
Some checks failed
deploy / deploy (push) Has been cancelled
security / security (push) Has been cancelled
Новый workflow .gitea/workflows/security.yml — параллельно с deploy.yml,
запускается на push в main + на PR. Все три инструмента warning-only:

- Hadolint: bad practices в Dockerfile
- GitLeaks: поиск секретов в истории (полный clone fetch-depth: 0)
- Semgrep: SAST с конфигами p/javascript + p/react + p/typescript + p/security-audit

Не блокируют deploy — findings собираются в логи job'а.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 16:51:01 +03:00
Dmitry Gusev
b902c28f54 feat(security): Trivy в CI + npm audit в Dockerfile (Layer A)
Some checks failed
deploy / deploy (push) Has been cancelled
- Dockerfile: npm audit HIGH/CRITICAL warning-only после npm ci/install
- CI (для тех у кого ещё не было): Trivy scan собранного образа
  HIGH/CRITICAL severity, --ignore-unfixed, --exit-code 0 (warning-only)

Часть multi-layer security plan: Layer A (минимум), B (Nuclei DAST weekly cron)
и C (GitLeaks + Semgrep + Hadolint) — отдельными задачами в Singularity.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 16:47:07 +03:00
Dmitry Gusev
c7f579f9ab feat(footer): hover-pill credit «Сделано в hhivp.com» с border вместо плотного фона
All checks were successful
deploy / deploy (push) Successful in 56s
Паттерн Cuberto: только mark по умолчанию, при hover/focus раскрывается с текстом справа. SVG-mark в currentColor наследует цвет родителя — гармонирует с любой темой. Border 1px solid currentColor вместо плотного чёрного фона.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 07:00:25 +03:00
Dmitry Gusev
fc50b0d1be style(footer): mark на чёрном фоне 22px (вместо синего 18px)
All checks were successful
deploy / deploy (push) Successful in 1m0s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 06:43:48 +03:00
Dmitry Gusev
987134600e feat(footer): робот hhivp вместо текстового mark в SVG
All checks were successful
deploy / deploy (push) Successful in 55s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 06:39:37 +03:00
Dmitry Gusev
682be4247f feat(footer): visible credit с маркой hhivp вместо длинного юр.лица
All checks were successful
deploy / deploy (push) Successful in 52s
Заменил «Сайт разработан и сопровождается… ООО АйТи Решения»
на компактный credit-блок: SVG-mark «h» 18px + «Разработка — hhivp.com».
SVG локально (public/hhivp-mark.svg), без cross-origin. hhivp.com
лучше для SEO direct match и memorability — юр.лицо остаётся в
Schema JSON-LD (invisible) для AI/Google.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 06:35:50 +03:00
Dmitry Gusev
382bfc4544 feat(schema): add creator entity (hhivp.com) to WebSite JSON-LD
All checks were successful
deploy / deploy (push) Successful in 1m10s
Entity-сигнал для AI Overviews / Я.Нейро о тех-партнёрстве.
Связывает сайт с разработчиком через @id графа.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 05:07:45 +03:00
Dmitry Gusev
04b405ea2e fix(cookie-banner): .cookie-consent[hidden] display: none !important
All checks were successful
deploy / deploy (push) Successful in 57s
Кнопка "Принять" не работала визуально: JS-handler срабатывал и устанавливал
banner.hidden=true, но CSS-правило .cookie-consent { display: flex } имело
ту же specificity (10) что UA stylesheet [hidden] { display: none } —
последняя из загруженных побеждала, и баннер не скрывался.
2026-05-24 01:03:51 +03:00
Dmitry Gusev
2cbd1358d2 style(footer): сделать dev-credit видимой — fg-muted цвет + dashed подчёркивание ссылки
All checks were successful
deploy / deploy (push) Successful in 1m1s
2026-05-24 01:02:34 +03:00
Dmitry Gusev
0cf7b23c87 feat(footer): добавить подпись о техническом партнёре (по образу stbolshevik)
All checks were successful
deploy / deploy (push) Successful in 58s
2026-05-24 00:21:27 +03:00
Dmitry Gusev
f78145e70a feat: Speculation Rules API in BaseLayout (prerender on hover/pointerdown)
All checks were successful
deploy / deploy (push) Successful in 1m1s
2026-05-23 23:56:37 +03:00
Dmitry Gusev
9499adfb9a chore(robots): add ChatGPT-User + OAI-SearchBot + Applebot-Extended
All checks were successful
deploy / deploy (push) Successful in 1m18s
2026-05-23 23:46:00 +03:00
bd6ab03b2e feat(seo): discoverability + Schema.org + IndexNow + Trivy (#1)
All checks were successful
deploy / deploy (push) Successful in 3m7s
2026-05-21 14:25:12 +03:00
25 changed files with 448 additions and 19 deletions

View File

@@ -0,0 +1,72 @@
name: security
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
security:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # GitLeaks нужна полная история
# ── 1. Hadolint: проверка Dockerfile ──────────────────────────────
# Установка нативного бинаря (act_runner не имеет docker внутри).
- name: Install Hadolint
run: |
if [ -f Dockerfile ]; then
curl -sSL https://github.com/hadolint/hadolint/releases/download/v2.12.0/hadolint-Linux-x86_64 -o /usr/local/bin/hadolint
chmod +x /usr/local/bin/hadolint
fi
- name: Run Hadolint
run: |
if [ -f Dockerfile ]; then
hadolint --no-fail Dockerfile || true
else
echo "No Dockerfile — skip"
fi
# ── 2. GitLeaks: поиск секретов в истории ─────────────────────────
- name: Install GitLeaks
run: |
curl -sSL https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz \
| tar -xz -C /usr/local/bin gitleaks
chmod +x /usr/local/bin/gitleaks
- name: Run GitLeaks
run: gitleaks detect --source . --no-banner --verbose --redact --exit-code 0 || true
# ── 3. Semgrep: SAST ──────────────────────────────────────────────
- name: Install Semgrep
run: |
apt-get update -qq
apt-get install -y --no-install-recommends python3-pip python3-venv
python3 -m venv /tmp/sg && /tmp/sg/bin/pip install --quiet semgrep
ln -sf /tmp/sg/bin/semgrep /usr/local/bin/semgrep
- name: Run Semgrep
run: |
semgrep --config=p/javascript --config=p/react --config=p/typescript --config=p/security-audit \
--severity=ERROR --severity=WARNING --no-error --quiet --metrics=off --timeout=120 . || true
# ── 4. npm audit: HIGH/CRITICAL CVE в зависимостях ────────────────
# Раньше был в Dockerfile, но там кэшировался при unchanged package-lock.json.
# Вынесен сюда — реально запускается каждый push.
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: npm audit
run: |
if [ -f package-lock.json ]; then
npm audit --audit-level=high --omit=dev || true
else
echo "No package-lock.json — skip npm audit"
fi

59
.github/workflows/security.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
name: security
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
schedule:
# Каждый понедельник 03:00 UTC — независимый weekly scan
- cron: '0 3 * * 1'
permissions:
contents: read
security-events: write
jobs:
hadolint:
runs-on: ubuntu-latest
if: hashFiles('Dockerfile') != ''
steps:
- uses: actions/checkout@v4
- uses: hadolint/hadolint-action@v3.1.0
with:
dockerfile: Dockerfile
failure-threshold: error
continue-on-error: true
gitleaks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v2
continue-on-error: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
semgrep:
runs-on: ubuntu-latest
container:
image: returntocorp/semgrep:latest
steps:
- uses: actions/checkout@v4
- run: semgrep ci --config=p/javascript --config=p/react --config=p/typescript --config=p/security-audit
continue-on-error: true
trivy:
runs-on: ubuntu-latest
if: hashFiles('Dockerfile') != ''
steps:
- uses: actions/checkout@v4
- uses: aquasecurity/trivy-action@master
with:
scan-type: fs
scan-ref: .
severity: HIGH,CRITICAL
ignore-unfixed: true
exit-code: '0'

12
.gitignore vendored
View File

@@ -1,3 +1,13 @@
# Security: НЕ коммитить production logs и CMS data exports
# (могут содержать API keys, JWT, private_key). См. инцидент 2026-05-24.
content/logs/
content/data/
*.production.log
*.production.log.*
ghost.json
ghost.*.json
*.ghost.*.json
# build output
dist/
.astro/
@@ -21,4 +31,4 @@ pnpm-debug.log*
.DS_Store
# jetbrains setting folder
.idea/
.idea/

49
.gitleaks.toml Normal file
View File

@@ -0,0 +1,49 @@
# GitLeaks config для сателлитных сайтов hhivp.
# Extends default rules and adds allowlist for known false-positives.
# https://github.com/gitleaks/gitleaks#configuration
[extend]
useDefault = true
[allowlist]
description = "Allowlist for IndexNow public keys + legacy WP plugin code"
# Пути, которые целиком игнорируем
paths = [
# IndexNow validation file (32-hex .txt в корне или в public/).
# Это публичный ключ, по дизайну отдаётся всем — НЕ секрет.
'''public/[a-f0-9]{32}\.txt''',
'''^[a-f0-9]{32}\.txt$''',
# IndexNow ping-скрипты содержат `const KEY = '<32hex>'` —
# тот же публичный ключ, не секрет (для авторизации перед Яндекс/Bing API).
'''scripts/indexnow\.(js|mjs|sh|ts)$''',
'''scripts/indexnow-ping\.sh$''',
# Legacy WordPress plugin code (akismet, jetpack, wpforms-lite, wp-cache).
# Все "ключи" внутри — placeholder/template/internal параметры,
# не настоящие секреты. Импортировано из старого WP-сайта как static.
'''wp-content/.*''',
# Минифицированные ассеты — часто содержат hash'и/токены, не секреты.
'''.*\.min\.(js|css)$''',
'''dist/.*''',
'''build/.*''',
# Защита на случай возврата CMS exports / production logs в репо
# (см. инцидент 2026-05-24 с Ghost ghost_private_key + members_private_key).
# Сами файлы УЖЕ удалены из history через git filter-repo, allowlist —
# дополнительная защита для будущих commit'ов.
'''content/logs/.*''',
'''content/data/.*''',
'''.*\.production\.log(\.[0-9]+)?$''',
'''.*\.ghost\..*\.json$''',
]
# Конкретные паттерны, которые false-positive
regexes = [
# Наш scripts/indexnow.js: const KEY = '<32-hex>' — IndexNow public key.
'''const\s+KEY\s*=\s*['"][a-f0-9]{32}['"]''',
# Аналог для других форм объявления того же ключа.
'''KEY\s*[:=]\s*['"][a-f0-9]{32}['"]''',
]

View File

@@ -75,7 +75,9 @@ src/
└── consts.ts (SITE_TITLE, WORLDS, ANALYTICS, CATEGORY_COLORS, plural())
public/
├── favicon.svg
├── favicon.svg (брендовый знак на тёмном фоне, оптимизирован под 16-32px)
├── favicon.ico (32×32 PNG-in-ICO, генерируется из favicon.svg)
├── apple-touch-icon.png (180×180 для iOS home screen)
├── logo.svg, logo-mark.svg
├── og-image.png (1200×630 для расшаривания в TG/VK/Twitter)
├── og-image.svg (исходник)
@@ -86,7 +88,8 @@ public/
scripts/
├── migrate-wp.mjs (одноразовый: _wp-export.json → src/content/*.md)
── build-og-image.mjs (sharp → public/og-image.png из SVG)
── build-og-image.mjs (sharp → public/og-image.png из SVG)
└── build-favicon.mjs (sharp → public/favicon.ico + apple-touch-icon.png из favicon.svg; `npm run build:favicon`)
Dockerfile (multi-stage: node:22-alpine builds → nginx:1.29-alpine serves)
nginx.conf (gzip, кэш _astro/ 1y, MIME application/rss+xml для feed)
@@ -132,6 +135,16 @@ docker compose build && docker compose up -d
В новой версии БД нет. Старый WP оставлен с БД `anotherreflctions_ru` (`sic`, с опечаткой) на `db.hhivp.com` (45.10.53.205, MySQL), user `u_anotherreflections`, prefix `anm_`. После 1-2 недель наблюдения за новой версией — старый WP-контейнер можно удалить, БД и snapshot тоже.
## SEO-title и description — уникальность
Шаблон в `BaseLayout.astro:16-17`: `title` рендерится как `${title} — ${SITE_TITLE}`, `description` falls back на `SITE_DESCRIPTION`. Если страница не передаёт свои `title`/`description`, получает дефолт → дубли в выдаче.
**Правила (поддерживать при добавлении страниц):**
- **Posts** (`src/content/posts/*.md`) — обязателен `description:` в frontmatter (Zod schema: `z.string().default('')`, но пустой даёт fallback). В SEO-title `[slug].astro` подставляет год публикации; если есть другой пост с тем же `title` за тот же год — подставляется полная дата (`DD месяц YYYY`).
- **Pages** (`src/content/pages/*.md`) — обязателен `description:`. `[slug].astro` берёт его напрямую.
- **Категории** (`category/[slug].astro`) — description вычисляется автоматически из количества публикаций и диапазона лет.
- **Главная** (`index.astro`) — свой description прописан явно.
## SEO/AI файлы
- `public/robots.txt` — статика, разрешено всё; явно перечислены GPTBot/ClaudeBot/Google-Extended/CCBot/PerplexityBot/anthropic-ai; ссылки на sitemap-index.xml и sitemap.txt

View File

@@ -3,6 +3,7 @@
# ─── Stage 1: build static site (Astro SSG) ────────────────────────────────
FROM node:22-alpine AS build
WORKDIR /app
SHELL ["/bin/sh", "-o", "pipefail", "-c"]
COPY package.json package-lock.json ./
RUN npm ci

45
SECURITY.md Normal file
View File

@@ -0,0 +1,45 @@
# Security Policy
## Supported versions
Только текущая версия `main` — site deployed continuously, отдельных release-веток нет.
## Reporting a vulnerability
Email: **admin@hhivp.com** (PGP опционально по запросу).
Просим **не публиковать** баг до подтверждения исправления (≤14 дней).
## Security stack (automated)
Этот репозиторий покрыт многоуровневой автоматической проверкой:
### Layer A — на каждый push в `main` (deploy CI)
- **Trivy** — сканирование Docker image на HIGH/CRITICAL CVE в OS-пакетах и npm-deps (warning-only, `--ignore-unfixed`).
- **npm audit** — проверка зависимостей на известные CVE в `Dockerfile` после `npm ci` (warning-only, `--audit-level=high --omit=dev`).
### Layer B — еженедельно (Sun 04:00 UTC, cron)
- **Nuclei** — DAST (Dynamic Application Security Testing) живого сайта. Шаблоны: exposures, misconfig, headers, CVE. Severity: HIGH+CRITICAL.
- Нотификация в Telegram при находках.
### Layer C — на каждый push в `main` + на MR/PR (`.gitea/workflows/security.yml`)
- **Hadolint** — bad practices в `Dockerfile`.
- **GitLeaks** — поиск секретов в git-истории (`fetch-depth: 0`).
- **Semgrep** — SAST (Static Application Security Testing) с конфигами `p/javascript`, `p/react`, `p/typescript`, `p/security-audit`.
Все три инструмента **warning-only** — не блокируют deploy, но finding'и видны в логах job'а.
### Дополнительно
- **152-ФЗ consent-gated analytics** — Я.Метрика и Google Analytics загружаются только после явного согласия пользователя в баннере cookies.
- **HTTPS-only** — Let's Encrypt cert, HSTS включён.
- **Security headers** — `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, `Content-Security-Policy`.
- **Rate limiting** — на `/api/contact` (5 req/min per IP) + Cloudflare Turnstile widget.
## Contact
- Email: admin@hhivp.com
- Site: https://hhivp.com/

View File

@@ -12,7 +12,8 @@
"preview": "astro preview",
"astro": "astro",
"migrate": "node scripts/migrate-wp.mjs",
"indexnow": "node scripts/indexnow.js"
"indexnow": "node scripts/indexnow.js",
"build:favicon": "node scripts/build-favicon.mjs"
},
"dependencies": {
"@astrojs/rss": "^4.0.12",

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 655 B

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,9 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="fg" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#b794ff"/>
<stop offset="1" stop-color="#6c4ed4"/>
</linearGradient>
<radialGradient id="bg" cx="50%" cy="38%" r="65%">
<stop offset="0" stop-color="#1a1430"/>
<stop offset="1" stop-color="#07090f"/>
</radialGradient>
</defs>
<rect width="64" height="64" rx="12" fill="url(#bg)"/>
<path d="M14 40 Q32 16 50 40" stroke="url(#fg)" stroke-width="3.5" stroke-linecap="round" fill="none"/>
<path d="M14 28 Q32 52 50 28" stroke="url(#fg)" stroke-width="3" stroke-linecap="round" fill="none" opacity="0.7"/>
<circle cx="32" cy="34" r="3.2" fill="#d4b9ff"/>
</svg>

Before

Width:  |  Height:  |  Size: 749 B

After

Width:  |  Height:  |  Size: 792 B

7
public/hhivp-mark.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 92 92" role="img" aria-label="hhivp" fill="currentColor">
<g transform="translate(-4 -2)">
<path d="M86.316,43.946V26.322h-8.331v9.961H70.46c-0.845-7.146-6.931-12.711-14.301-12.711H39.842c-7.37,0-13.456,5.564-14.301,12.711h-7.527v-6.162c3.013-1.554,4.934-4.675,4.934-8.083c0-3.775-2.382-7.199-5.928-8.519l-1.012-0.377v10.897H11.69V13.142l-1.012,0.376c-3.546,1.32-5.929,4.744-5.929,8.52c0,3.408,1.921,6.529,4.934,8.083v17.623h8.331v-9.96h7.428c-0.001,0.067-0.01,0.132-0.01,0.199v18.024c0,5.872,3.535,10.926,8.584,13.169l-1.052,4.606c-1.821,1.184-3.031,3.232-3.031,5.561c0,2.352,3.573,3.186,6.632,3.186c3.059,0,6.632-0.834,6.632-3.186c0-2.168-1.05-4.091-2.664-5.302l2.324-3.625h10.283l2.324,3.625c-1.614,1.211-2.664,3.133-2.664,5.301c0,2.352,3.573,3.186,6.632,3.186c3.06,0,6.632-0.834,6.632-3.186c0-2.33-1.209-4.378-3.03-5.562l-1.05-4.606c5.049-2.244,8.583-7.297,8.583-13.169V37.982c0-0.067-0.009-0.132-0.01-0.199h7.427v6.163c-3.012,1.553-4.932,4.674-4.932,8.083c0,3.775,2.382,7.199,5.928,8.52l1.012,0.377V50.027h4.317v10.898l1.012-0.377c3.545-1.32,5.928-4.744,5.928-8.52C91.25,48.62,89.329,45.5,86.316,43.946z M16.957,28.966l-0.442,0.199v17.079h-5.331V29.165l-0.442-0.199C8.013,27.74,6.25,25.021,6.25,22.038c0-2.778,1.546-5.329,3.94-6.649v10.151h7.318V15.388c2.394,1.321,3.939,3.872,3.939,6.649C21.448,25.021,19.685,27.74,16.957,28.966z M41.698,79.343c0,0.814-2.062,1.686-5.132,1.686c-3.07,0-5.132-0.872-5.132-1.686c0-2.83,2.303-5.132,5.133-5.132C39.396,74.211,41.698,76.513,41.698,79.343z M64.566,79.343c0,0.814-2.062,1.686-5.132,1.686c-3.07,0-5.132-0.872-5.132-1.686c0-2.83,2.303-5.132,5.133-5.132C62.264,74.211,64.566,76.513,64.566,79.343z M69.069,56.006c0,7.119-5.792,12.911-12.911,12.911H39.842c-7.119,0-12.91-5.792-12.91-12.911V37.982c0-7.119,5.792-12.91,12.91-12.91h16.316c7.119,0,12.911,5.792,12.911,12.91V56.006z M85.811,58.679V48.527h-7.317v10.151c-2.394-1.322-3.939-3.872-3.939-6.65c0-2.983,1.762-5.703,4.489-6.928l0.443-0.199v-17.08h5.331v17.08l0.443,0.199c2.728,1.226,4.491,3.945,4.491,6.928C89.75,54.808,88.205,57.358,85.811,58.679z"/>
<circle cx="40.222" cy="40.07" r="3.8"/>
<circle cx="55.78" cy="40.07" r="3.8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -13,9 +13,18 @@ Host: https://anotherreflections.ru
User-agent: GPTBot
Allow: /
User-agent: ChatGPT-User
Allow: /
User-agent: OAI-SearchBot
Allow: /
User-agent: ClaudeBot
Allow: /
User-agent: Applebot-Extended
Allow: /
User-agent: Google-Extended
Allow: /

28
scripts/build-favicon.mjs Normal file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env node
// Генерирует public/favicon.ico (32x32 PNG-in-ICO, как у Astro по умолчанию)
// и public/apple-touch-icon.png (180x180) из public/favicon.svg.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const svgPath = path.join(ROOT, 'public/favicon.svg');
const svg = fs.readFileSync(svgPath);
const targets = [
{ out: 'public/favicon.ico', size: 32 },
{ out: 'public/apple-touch-icon.png', size: 180 },
];
for (const { out, size } of targets) {
const dst = path.join(ROOT, out);
await sharp(svg, { density: 384 })
.resize(size, size)
.png({ compressionLevel: 9 })
.toFile(dst);
const kb = (fs.statSync(dst).size / 1024).toFixed(1);
console.log(`wrote ${out} (${size}x${size}) — ${kb} KB`);
}

View File

@@ -5,6 +5,7 @@ legacyId: "132"
menuOrder: "4"
pubDate: "2011-05-18T00:40:47+03:00"
updatedDate: "2014-07-14T00:12:19+03:00"
description: "Игровая вселенная по «Хроникам Амбера» Роджера Желязны: Истинный мир, его Отражения и принцы крови с непростой семейной историей."
---
Тест

View File

@@ -5,6 +5,7 @@ legacyId: "130"
menuOrder: "5"
pubDate: "2011-05-18T00:39:40+03:00"
updatedDate: "2014-07-14T00:12:27+03:00"
description: "Ролевая игра по циклу «Киндрэт» Алексея Пехова, Елены Бычковой и Натальи Турчаниновой: вампирские кланы ночной Столицы и их теневая политика."
---
«Киндрэт. Кровные братья» — первая книга цикла Киндрэт известных российских писателей Алексея Пехова, Елены Бычковой и Натальи Турчаниновой, рассказывающая о жизни кланов вампиров. В цикл романов по миру ночной Столицы вошли 4 книги: «Кровные братья», «Колдун из клана смерти», «Основатель», «Новые боги».

View File

@@ -5,6 +5,7 @@ legacyId: "124"
menuOrder: "3"
pubDate: "2011-05-18T00:27:35+03:00"
updatedDate: "2014-07-14T00:12:09+03:00"
description: "Главная ролевая игра «Иных Отражений» по миру «Дозоров» Сергея Лукьяненко и Владимира Васильева: Иные, Свет и Тьма, древний Договор."
---
**Иные Отражения: Сумерки Дозоров**

View File

@@ -5,6 +5,7 @@ legacyId: "339"
menuOrder: "2"
pubDate: "2013-07-09T13:29:47+03:00"
updatedDate: "2025-06-07T02:31:08+03:00"
description: "Партнёры и друзья ролевой группы «Иные Отражения»: ООО «АйТи Решения» (hhivp.com) — IT-услуги для бизнеса в Москве и области."
---
[ООО "АйТи Решения"](https://hhivp.com) предоставляет полный спектр IT услуг на территории Москвы и Московской области, как частным лицам, так и представителям бизнеса. Мы способствуем развитию Вашего бизнеса и достижению самых смелых результатов!

View File

@@ -5,6 +5,7 @@ legacyId: "137"
menuOrder: "1"
pubDate: "2011-05-18T00:50:11+03:00"
updatedDate: "2014-07-14T00:10:31+03:00"
description: "История ролевой группы «Иные Отражения»: основана в 2006 году как «Иные Миры», в 2007-м объединилась с проектом «Отражения»."
---
**Ролевая группа "Иные Отражения"**

View File

@@ -5,6 +5,7 @@ legacyId: "378"
menuOrder: "6"
pubDate: "2013-10-06T02:59:22+03:00"
updatedDate: "2014-07-14T00:12:33+03:00"
description: "«Ренессанс» — ролевая игра по миру «Дозоров» Сергея Лукьяненко: Иные, Сумрак и древний Договор в декорациях эпохи Возрождения."
---
**Иные Отражения: Ренессанс**

View File

@@ -24,10 +24,21 @@ const jsonLd = [
{
'@context': 'https://schema.org',
'@type': 'WebSite',
'@id': `${SITE_URL}/#website`,
name: SITE_TITLE,
url: SITE_URL,
inLanguage: SITE_LANG,
description: SITE_DESCRIPTION,
publisher: { '@id': `${SITE_URL}/#org` },
// creator — тех-партнёр, разработчик сайта (hhivp.com). Entity-сигнал
// для AI Overviews / Я.Нейро о связи между сайтом и его создателем.
creator: {
'@type': 'Organization',
'@id': 'https://hhivp.com/#organization',
name: 'ООО «АйТи Решения»',
url: 'https://hhivp.com/',
sameAs: ['https://hhivp.com'],
},
potentialAction: {
'@type': 'SearchAction',
target: `${SITE_URL}/?s={query}`,
@@ -37,6 +48,7 @@ const jsonLd = [
{
'@context': 'https://schema.org',
'@type': 'NewsMediaOrganization',
'@id': `${SITE_URL}/#org`,
name: SITE_TITLE,
url: SITE_URL,
logo: logoUrl,
@@ -57,6 +69,8 @@ const jsonLd = [
<meta name="theme-color" content="#07090f" />
<link rel="canonical" href={canonical} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" sizes="32x32" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta property="og:type" content={ogType} />
<meta property="og:title" content={pageTitle} />
@@ -82,6 +96,28 @@ const jsonLd = [
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Cormorant+Garamond:wght@500;600;700&display=swap" rel="stylesheet" />
<Analytics />
<!--
Speculation Rules API (Chromium 122+) — prerender same-origin pages on
hover/pointerdown for near-instant navigation. Rules are static JSON,
no user input → safe inline.
-->
<script type="speculationrules" is:inline set:html={JSON.stringify({
prerender: [
{
where: {
and: [
{ href_matches: '/*' },
{ not: { href_matches: '/assets/*' } },
{ not: { href_matches: '/sitemap*' } },
{ not: { href_matches: '/feed*' } },
{ not: { href_matches: '/llms*' } }
]
},
eagerness: 'moderate'
}
]
})} />
</head>
<body>
<header class="site-header">
@@ -116,6 +152,14 @@ const jsonLd = [
<a href="/privacy/">Политика конфиденциальности</a> ·
<a href="/kontakty/">Контакты</a>
</p>
<p class="dev-credit">
<a href="https://hhivp.com/" target="_blank" rel="noopener" class="hhivp-credit" aria-label="Сделано в hhivp.com">
<span class="hhivp-credit-mark" aria-hidden="true">
<img src="/hhivp-mark.svg" alt="" width="20" height="20" />
</span>
<span class="hhivp-credit-text">Сделано&nbsp;в&nbsp;hhivp.com</span>
</a>
</p>
</footer>
<CookieConsent />

View File

@@ -6,13 +6,27 @@ import { CATEGORY_COLORS } from '../consts';
export async function getStaticPaths() {
const posts = await getCollection('posts');
const pages = await getCollection('pages');
// Считаем сколько постов с одинаковым {title, year} — если >1, в SEO-title
// подставим полную дату, иначе только год.
const titleYearCount = new Map<string, number>();
for (const p of posts) {
const key = `${p.data.title}|${p.data.pubDate.getFullYear()}`;
titleYearCount.set(key, (titleYearCount.get(key) ?? 0) + 1);
}
return [
...posts.map((p) => ({ params: { slug: p.data.slug }, props: { entry: p, kind: 'post' as const } })),
...pages.map((p) => ({ params: { slug: p.data.slug }, props: { entry: p, kind: 'page' as const } })),
...posts.map((p) => {
const year = p.data.pubDate.getFullYear();
const key = `${p.data.title}|${year}`;
const sameYearDup = (titleYearCount.get(key) ?? 1) > 1;
return { params: { slug: p.data.slug }, props: { entry: p, kind: 'post' as const, sameYearDup } };
}),
...pages.map((p) => ({ params: { slug: p.data.slug }, props: { entry: p, kind: 'page' as const, sameYearDup: false } })),
];
}
const { entry, kind } = Astro.props;
const { entry, kind, sameYearDup } = Astro.props;
const { Content } = await render(entry);
// Буквица — только для постов с телом длиннее короткого порога.
const bodyLen = entry.body?.length ?? 0;
@@ -20,10 +34,18 @@ const useDropCap = kind === 'post' && bodyLen > 240;
const fmtDate = (d: Date) =>
d.toLocaleDateString('ru-RU', { year: 'numeric', month: 'long', day: 'numeric' });
// Короткая дата без "г." для SEO-title постов с одинаковым названием в одном году.
const fmtDateForTitle = (d: Date) =>
d.toLocaleDateString('ru-RU', { year: 'numeric', month: 'long', day: 'numeric' }).replace(/\sг\.?$/, '');
const seoTitle = kind === 'post'
? `${entry.data.title} (${sameYearDup ? fmtDateForTitle(entry.data.pubDate) : entry.data.pubDate.getFullYear()})`
: entry.data.title;
---
<BaseLayout
title={entry.data.title}
description={kind === 'post' ? entry.data.description : undefined}
title={seoTitle}
description={entry.data.description || undefined}
ogType={kind === 'post' ? 'article' : 'website'}
>
<article class="post">

View File

@@ -27,8 +27,18 @@ const sorted = posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.va
const fmtDate = (d: Date) =>
d.toLocaleDateString('ru-RU', { year: 'numeric', month: 'long', day: 'numeric' });
const yearsRange = (() => {
if (!sorted.length) return '';
const years = sorted.map((p) => p.data.pubDate.getFullYear());
const min = Math.min(...years);
const max = Math.max(...years);
return min === max ? `${min}` : `${min}${max}`;
})();
const pubWord = plural(sorted.length, ['публикация', 'публикации', 'публикаций']);
const catDescription = `${name} — ${sorted.length} ${pubWord}${yearsRange ? ` (${yearsRange})` : ''} в архиве ролевой группы «Иные Отражения».`;
---
<BaseLayout title={`Категория: ${name}`}>
<BaseLayout title={`Категория: ${name}`} description={catDescription}>
<section class="hero hero-compact" style={`--cat-color: ${catColor};`}>
<span class="hero-eyebrow" style={`color: ${catColor}; border-color: ${catColor};`}>Категория</span>
<h1 style={`background: linear-gradient(180deg, #ffffff 0%, ${catColor} 100%); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;`}>{name}</h1>

View File

@@ -13,7 +13,7 @@ const oldestYear = posts.length ? posts[posts.length - 1].data.pubDate.getFullYe
const fmtDate = (d: Date) =>
d.toLocaleDateString('ru-RU', { year: 'numeric', month: 'long', day: 'numeric' });
---
<BaseLayout>
<BaseLayout description="Главная ролевой группы «Иные Отражения»: новости, хроника публикаций с 2009 года и игровые миры — Дозоры, Амбер, Киндрэт, Ренессанс, Над бездной, Глубина, Warhammer 40k.">
<section class="hero">
<span class="hero-eyebrow">Ролевой проект · с {SITE_FOUNDED} года</span>
<h1>Иные<br/>Отражения</h1>

View File

@@ -580,6 +580,7 @@ pre {
}
/* ============================== COOKIE CONSENT ============================== */
.cookie-consent[hidden] { display: none !important; }
.cookie-consent {
position: fixed;
left: 1rem;
@@ -723,6 +724,51 @@ pre {
.site-footer p { margin: 0 0 .3em; }
.site-footer a { color: var(--fg-muted); border-bottom-color: var(--border); }
.site-footer a:hover { color: var(--accent); border-bottom-color: var(--accent); }
.site-footer .dev-credit {
margin-top: 0.9rem;
font-size: 0.8rem;
color: var(--fg-muted);
}
.site-footer .dev-credit a {
color: var(--fg-muted);
border-bottom: 1px dashed currentColor;
}
.site-footer .dev-credit a:hover { color: var(--accent); border-bottom-color: var(--accent); }
/* hhivp-credit — pill mark + slide-out text on hover */
.hhivp-credit {
display: inline-flex;
align-items: center;
vertical-align: middle;
text-decoration: none;
color: inherit;
border: 1px solid currentColor;
border-radius: 999px;
padding: 3px;
gap: 0;
line-height: 1;
opacity: 0.65;
transition: gap 240ms ease, padding-right 240ms ease, opacity 240ms ease;
}
.hhivp-credit:hover,
.hhivp-credit:focus-visible {
opacity: 1;
gap: 8px;
padding-right: 12px;
outline: none;
border-bottom: 1px solid currentColor;
}
.hhivp-credit-mark { display: inline-flex; flex-shrink: 0; }
.hhivp-credit-mark img { display: block; width: 20px; height: 20px; }
.hhivp-credit-text {
display: inline-block;
overflow: hidden;
white-space: nowrap;
max-width: 0;
font-size: 0.75rem;
transition: max-width 240ms ease;
}
.hhivp-credit:hover .hhivp-credit-text,
.hhivp-credit:focus-visible .hhivp-credit-text { max-width: 200px; }
.footer-ornament {
display: block;
width: 60px;