Compare commits

..

3 Commits

Author SHA1 Message Date
Dmitry Gusev
3fa659fb7d ci: add Trivy image scan to Gitea Actions
Между `docker compose build` и `docker compose up -d` сканируется свежий
образ `anotherreflections-ru-v2:latest`. Severity HIGH+CRITICAL, exit-code 0
(не блокирует деплой первое время). Образ Trivy с ghcr.io — обход
Docker Hub rate limit (как в hhivp-website коммит 0189256).
2026-05-21 13:45:24 +03:00
Dmitry Gusev
291d5a5a98 feat(indexnow): add IndexNow key file and trigger script
- public/15455d9f2c7b473bb04336055b792ec9.txt — ownership-ключ для Yandex/Bing
- scripts/indexnow.js — собирает URL из dist/sitemap-*.xml (fallback: remote sitemap-index), шлёт на yandex.com/indexnow и api.indexnow.org/indexnow
- package.json: npm run indexnow
- .gitea/workflows/deploy.yml: после успешного deploy чекаут + node + npm run indexnow (не блокирует пайплайн через || true)
2026-05-21 13:44:11 +03:00
Dmitry Gusev
86d2aa6c14 feat(seo): add security.txt, humans.txt, Schema.org JSON-LD
- public/.well-known/security.txt (RFC 9116): контакты для responsible disclosure
- public/humans.txt: команда и стек
- BaseLayout.astro: JSON-LD WebSite (SearchAction) + NewsMediaOrganization (sameAs из SOCIAL)
2026-05-21 13:41:27 +03:00
25 changed files with 19 additions and 448 deletions

View File

@@ -1,72 +0,0 @@
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

View File

@@ -1,59 +0,0 @@
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,13 +1,3 @@
# 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/
@@ -31,4 +21,4 @@ pnpm-debug.log*
.DS_Store
# jetbrains setting folder
.idea/
.idea/

View File

@@ -1,49 +0,0 @@
# 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,9 +75,7 @@ src/
└── consts.ts (SITE_TITLE, WORLDS, ANALYTICS, CATEGORY_COLORS, plural())
public/
├── favicon.svg (брендовый знак на тёмном фоне, оптимизирован под 16-32px)
├── favicon.ico (32×32 PNG-in-ICO, генерируется из favicon.svg)
├── apple-touch-icon.png (180×180 для iOS home screen)
├── favicon.svg
├── logo.svg, logo-mark.svg
├── og-image.png (1200×630 для расшаривания в TG/VK/Twitter)
├── og-image.svg (исходник)
@@ -88,8 +86,7 @@ public/
scripts/
├── migrate-wp.mjs (одноразовый: _wp-export.json → src/content/*.md)
── 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`)
── build-og-image.mjs (sharp → public/og-image.png из SVG)
Dockerfile (multi-stage: node:22-alpine builds → nginx:1.29-alpine serves)
nginx.conf (gzip, кэш _astro/ 1y, MIME application/rss+xml для feed)
@@ -135,16 +132,6 @@ 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,7 +3,6 @@
# ─── 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

View File

@@ -1,45 +0,0 @@
# 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,8 +12,7 @@
"preview": "astro preview",
"astro": "astro",
"migrate": "node scripts/migrate-wp.mjs",
"indexnow": "node scripts/indexnow.js",
"build:favicon": "node scripts/build-favicon.mjs"
"indexnow": "node scripts/indexnow.js"
},
"dependencies": {
"@astrojs/rss": "^4.0.12",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 655 B

View File

@@ -1,16 +1,9 @@
<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 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>

Before

Width:  |  Height:  |  Size: 792 B

After

Width:  |  Height:  |  Size: 749 B

View File

@@ -1,7 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -13,18 +13,9 @@ 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: /

View File

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

View File

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

View File

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

View File

@@ -24,21 +24,10 @@ 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}`,
@@ -48,7 +37,6 @@ const jsonLd = [
{
'@context': 'https://schema.org',
'@type': 'NewsMediaOrganization',
'@id': `${SITE_URL}/#org`,
name: SITE_TITLE,
url: SITE_URL,
logo: logoUrl,
@@ -69,8 +57,6 @@ 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} />
@@ -96,28 +82,6 @@ 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">
@@ -152,14 +116,6 @@ 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,27 +6,13 @@ 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) => {
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 } })),
...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 } })),
];
}
const { entry, kind, sameYearDup } = Astro.props;
const { entry, kind } = Astro.props;
const { Content } = await render(entry);
// Буквица — только для постов с телом длиннее короткого порога.
const bodyLen = entry.body?.length ?? 0;
@@ -34,18 +20,10 @@ 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={seoTitle}
description={entry.data.description || undefined}
title={entry.data.title}
description={kind === 'post' ? entry.data.description : undefined}
ogType={kind === 'post' ? 'article' : 'website'}
>
<article class="post">

View File

@@ -27,18 +27,8 @@ 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}`} description={catDescription}>
<BaseLayout title={`Категория: ${name}`}>
<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 description="Главная ролевой группы «Иные Отражения»: новости, хроника публикаций с 2009 года и игровые миры — Дозоры, Амбер, Киндрэт, Ренессанс, Над бездной, Глубина, Warhammer 40k.">
<BaseLayout>
<section class="hero">
<span class="hero-eyebrow">Ролевой проект · с {SITE_FOUNDED} года</span>
<h1>Иные<br/>Отражения</h1>

View File

@@ -580,7 +580,6 @@ pre {
}
/* ============================== COOKIE CONSENT ============================== */
.cookie-consent[hidden] { display: none !important; }
.cookie-consent {
position: fixed;
left: 1rem;
@@ -724,51 +723,6 @@ 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;