Compare commits

28 Commits

Author SHA1 Message Date
striker
71b37935c2 docs: добавить SEO-правила и заметку по RSS-агрегатору в CLAUDE.md
All checks were successful
deploy / deploy (push) Successful in 52s
security / security (push) Successful in 2m40s
2026-05-30 21:09:25 +03:00
striker
93c16e9ada fix(seo): добавить description всем 11 страницам и постам (были пустыми)
All checks were successful
deploy / deploy (push) Successful in 1m11s
security / security (push) Successful in 2m50s
2026-05-30 20:12:42 +03:00
striker
f006eedf71 chore(security): .gitignore + .gitleaks.toml защита от CMS-export leak
All checks were successful
deploy / deploy (push) Successful in 59s
security / security (push) Successful in 2m37s
Превентивная защита от случайной публикации 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:27 +03:00
striker
d27b2f6719 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 2m41s
- 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:16 +03:00
striker
c2704ec3a1 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:06 +03:00
striker
a32346fc3d feat(security): GitHub Actions security.yml (mirror coverage)
All checks were successful
deploy / deploy (push) Successful in 48s
security / security (push) Successful in 3m6s
Дополнительный 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:35 +03:00
striker
fb613d0b02 docs(readme): README с описанием стека, deploy и security (GitHub mirror)
Some checks failed
deploy / deploy (push) Has been cancelled
security / security (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 17:52:56 +03:00
striker
708ee41596 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:24 +03:00
striker
bdccf62cee docs(security): SECURITY.md с описанием Layer A+B+C stack
All checks were successful
deploy / deploy (push) Successful in 1m5s
security / security (push) Successful in 9s
Описывает все автоматические проверки: 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:42 +03:00
striker
5689940853 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:04 +03:00
striker
3a2d66eb6c 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:09 +03:00
striker
f4923fd6b4 feat(footer): hover-pill credit «Сделано в hhivp.com» с border вместо плотного фона
All checks were successful
deploy / deploy (push) Successful in 54s
Паттерн 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:27 +03:00
striker
e79fdf1c59 style(footer): mark на чёрном фоне 22px (вместо синего 18px)
All checks were successful
deploy / deploy (push) Successful in 1m16s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 06:43:50 +03:00
striker
29a5faadee feat(footer): робот hhivp вместо текстового mark в SVG
All checks were successful
deploy / deploy (push) Successful in 1m11s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 06:39:38 +03:00
striker
3868774a2b feat(footer): visible credit с маркой hhivp вместо длинного юр.лица
All checks were successful
deploy / deploy (push) Successful in 1m5s
SVG-mark 18px + «Разработка — hhivp.com» вместо «ООО АйТи Решения».
hhivp.com лучше для SEO direct match и memorability.

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 05:07:49 +03:00
striker
182040c633 feat(footer): добавить подпись о техническом партнёре (по образу stbolshevik)
All checks were successful
deploy / deploy (push) Successful in 55s
2026-05-24 00:21:31 +03:00
striker
98c91f605b feat: Speculation Rules API in BaseLayout (prerender on hover/pointerdown)
All checks were successful
deploy / deploy (push) Successful in 1m10s
2026-05-23 23:56:41 +03:00
striker
3104eaf1ce chore(robots): add ChatGPT-User + OAI-SearchBot + Applebot-Extended
Some checks failed
deploy / deploy (push) Has been cancelled
2026-05-23 23:46:06 +03:00
d2bd3647d4 fix(ci): run trivy via SSH on web (docker not in Gitea runner) (#2)
All checks were successful
deploy / deploy (push) Successful in 1m3s
2026-05-21 15:31:52 +03:00
ffb3f94a57 feat(seo): discoverability + Schema.org + IndexNow + Trivy (#1)
Some checks failed
deploy / deploy (push) Failing after 13s
2026-05-21 14:25:14 +03:00
striker
cf17c6e432 docs: CLAUDE.md полностью под Astro v2
All checks were successful
deploy / deploy (push) Successful in 47s
2026-05-21 03:36:17 +03:00
striker
78fedc59cf ci: cron-агрегатор внешних RSS (scripts/package.json + install-cron.sh)
All checks were successful
deploy / deploy (push) Successful in 48s
- Изолированный scripts/package.json с одним fast-xml-parser, чтобы не раздувать root node_modules на хосте
- scripts/install-cron.sh — настройка cron /etc/cron.d/pushkino-rss-aggregator, hourly /usr/bin/node pull-external-rss.mjs → data/news.json
- logrotate weekly × 4 для /var/log/pushkino-rss-aggregator.log
- Bind-mount data/ уже подцеплен в docker-compose как /var/lib/pushkino/data:ro
2026-05-21 03:31:33 +03:00
striker
87b44d07c6 ci: use SSH URL for git clone in deploy (drop GITEA_TOKEN)
All checks were successful
deploy / deploy (push) Successful in 53s
2026-05-21 03:28:53 +03:00
striker
0e758f3a1a nginx: map по $uri (декодированному) для cyrillic legacy-slugs
Some checks failed
deploy / deploy (push) Failing after 9s
2026-05-21 03:25:34 +03:00
striker
5dddb68803 nginx: vhost для Astro-контейнера (proxy на :4146, 301-карта WP-cyrillic slugs + старый /feed/ → /feed.xml)
Some checks failed
deploy / deploy (push) Failing after 8s
2026-05-21 03:24:12 +03:00
striker
2edb852da3 chore: ignore .vite cache
Some checks failed
deploy / deploy (push) Failing after 10s
2026-05-21 03:21:45 +03:00
striker
c65e07cd98 rewrite: Vite+React → Astro 5 + Content Collections
Some checks failed
deploy / deploy (push) Failing after 12s
- Бэкап старой версии на ветке vite-react-backup
- Stack: Astro 5 + nginx:alpine runtime, образ ~50 МБ (был ~600 МБ)
- @astrojs/rss заменён ручным buildRss() — гарантия CDATA в content:encoded для IPB Importer
- @astrojs/sitemap → sitemap-index.xml + sitemap.txt
- 152-ФЗ cookie consent + privacy.astro + Analytics с gating
- AI-файлы: robots.txt с явным allow для AI-краулеров, ai.txt, llms.txt
- Гибридный визуал: фото-фон шапки (аэрофото Пушкино) + PT Serif + IBM Plex Sans
- Иерархия: hero "Главная история" с рамкой + "Ещё из истории" + "Хроника"
- Категория "main" (псевдо) скрыта из плашек и из Рубрик в сайдбаре
- hideFromList: true для технических постов
- featuredImage в frontmatter для постов без хорошей первой <img>
- WP resized-URL (-WxH.ext) автоматически → оригинал
- CI/CD: .gitea/workflows/deploy.yml (push → SSH-build)
- Внешние RSS: scripts/pull-external-rss.mjs пишет news.json в bind-mount, фронт фетчит client-side
2026-05-21 03:21:31 +03:00
87 changed files with 6786 additions and 4238 deletions

View File

@@ -0,0 +1,78 @@
name: deploy
on:
push:
branches: [main]
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install SSH client
run: |
apt-get update -qq
apt-get install -y --no-install-recommends openssh-client
- name: Setup SSH
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s\n' "${{ secrets.SSH_DEPLOY_KEY }}" > ~/.ssh/id_deploy
chmod 600 ~/.ssh/id_deploy
printf '%s\n' "${{ secrets.SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
- name: Deploy + Trivy scan to web.hhivp.com
run: |
ssh -i ~/.ssh/id_deploy striker@web.hhivp.com bash -s <<'REMOTE'
set -euo pipefail
REPO_URL="ssh://git@git.striker.su:2222/striker/pushkinohistory-ru-v2.git"
DEPLOY_PATH="/opt/docker/sites/pushkinohistory-ru-v2"
HEALTH_URL="http://127.0.0.1:4146/"
if [ ! -d "$DEPLOY_PATH/.git" ]; then
mkdir -p "$DEPLOY_PATH"
git clone --branch main "$REPO_URL" "$DEPLOY_PATH"
else
cd "$DEPLOY_PATH"
git remote set-url origin "$REPO_URL"
git fetch origin main
git reset --hard origin/main
fi
cd "$DEPLOY_PATH"
mkdir -p data
docker compose build
# Trivy scan свежесобранного образа на хосте (docker есть здесь).
# ghcr.io вместо docker.io — обход rate limit. HIGH/CRITICAL warning-only.
echo "=== Trivy scan: pushkinohistory-ru-v2:latest ==="
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /tmp/trivy-cache:/root/.cache/ \
ghcr.io/aquasecurity/trivy:latest image \
--severity HIGH,CRITICAL \
--ignore-unfixed \
--no-progress \
--exit-code 0 \
--timeout 5m \
pushkinohistory-ru-v2:latest || true
echo "=== Trivy scan done ==="
docker compose up -d
sleep 5
docker compose ps
curl -fsS -o /dev/null -w "HEALTH HTTP %{http_code}\n" "$HEALTH_URL"
docker image prune -af --filter "until=168h" >/dev/null 2>&1 || true
REMOTE
- name: Setup Node.js for IndexNow
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Notify IndexNow
run: npm run indexnow || true

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'

28
.gitignore vendored
View File

@@ -1,10 +1,34 @@
# 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
node_modules/
dist/
.astro/
.env
.env.local
*.log
.DS_Store
Thumbs.db
.vite/
data/news.json
# generated content (исходник в БД и/или в посты-md)
scripts/posts-raw.jsonl
# cron-агрегатор внешних RSS (данные пишутся в раннее)
data/news.json
# screenshot debug-помойка
poc-*.jpeg
poc-*.png
debug-*.jpeg
debug-*.png
# Vite-React backup (доступен через ветку vite-react-backup на origin)
.vite/

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}['"]''',
]

195
CLAUDE.md
View File

@@ -1,91 +1,190 @@
# pushkinohistory.ru — Vite+React v2
# pushkinohistory.ru — Astro v2
Сайт «История города Пушкино». Редизайн с WordPress (v1, контейнер `pushkinohistory-ru:4143`) на статический Vite+React+Tailwind (контейнер `pushkinohistory-ru-v2:4146`).
Сайт «История города Пушкино». Редизайн с WordPress (v1) на статический Astro 5 + Content Collections + markdown.
**Прод:** https://pushkinohistory.ru
**Репо:** `git.striker.su/striker/pushkinohistory-ru-v2`
**Хост:** `web.hhivp.com` (45.10.53.206 / 45.10.53.242)
**Контейнер:** `pushkinohistory-ru-v2` на `127.0.0.1:4146` (nginx:alpine + Astro SSG)
**Cutover:** 2026-05-21 со старого WP-контейнера `pushkinohistory-ru:4143`
## Стек
- Vite 6 + React 18 + Tailwind 3
- PT Serif (заголовки/основной текст) + IBM Plex Sans (UI)
- Express 4 + compression (runtime сервер, отдаёт prerendered HTML + `/api/news.json` + `/feed/`)
- Puppeteer для prerender (chromium в build-стадии)
- fast-xml-parser для агрегатора внешних RSS
- **Astro 5** + Content Collections + markdown
- **nginx:1.29-alpine** в runtime-контейнере (статика + bind-mount для агрегатора новостей)
- **sanitize-html** — очистка тела поста для RSS `<content:encoded>` (с CDATA)
- **fast-xml-parser** — изолированно в `scripts/` (только для cron-агрегатора)
- **sharp** (devDep, опц.) — генерация OG-image PNG из SVG
- **PT Serif** (заголовки/тело статьи) + **IBM Plex Sans** (UI)
- **@astrojs/sitemap** — `sitemap-index.xml` автоматически
## Структура
```
src/
App.jsx # клиентский роутер (window.history + popstate), 301-карта oldSlug → newSlug
components/ # Header, Sidebar, Footer, PostCard
content/ # JSON-контент: posts, pages, partners, ads, transport, feeds
pages/ # Home, Post, Page, Category, News, NotFound
server/index.js # Express: /api/health, /api/news.json, /uploads/, /feed/, статика
├── content/
│ ├── posts/*.md (7 постов, мигрированы из WP DB ph_posts)
├── pages/*.md (4 страницы: Главная-приветствие, История, Фото, Форум)
└── _categories.json (через categorySlugs в frontmatter)
├── components/
│ ├── Header.astro (фото-шапка + аэрофото Пушкино + sepia overlay)
│ ├── Sidebar.astro (Транспорт, Рубрики с подсчётом, Партнёры, Объявления)
│ ├── Footer.astro
│ ├── PostCard.astro (featured / has-thumb / no-thumb варианты)
│ ├── CookieConsent.astro (152-ФЗ баннер, ph-consent в localStorage+cookie)
│ └── Analytics.astro (Яндекс.Метрика + GA, gating через type=text/plain)
├── layouts/BaseLayout.astro
├── pages/
│ ├── index.astro (Главная история + Ещё из истории + Хроника)
│ ├── [slug].astro (один пост или одна страница)
│ ├── cat/[slug].astro (рубрика)
│ ├── cat/[slug]/feed.xml.ts (per-category RSS)
│ ├── news.astro (агрегатор внешних RSS, фетчит /api/news.json)
│ ├── 404.astro
│ ├── privacy.astro (политика + кнопка «Отозвать согласие»)
│ ├── feed.xml.ts (общий RSS, с RSS_CUTOFF)
│ └── sitemap.txt.ts (plain-text карта)
├── lib/
│ ├── extract.ts (firstImage, plainText, formatDateRu)
│ └── rss-helpers.ts (buildRss, sanitizeForRss, cdata, plainTextExcerpt)
├── data/ (внешний контент-конфиг, JSON)
│ ├── transport.json (ссылки на Yandex.Schedules / mostransport)
│ ├── partners.json
│ ├── ads.json
│ └── feeds.json (внешние RSS-источники для cron-агрегатора)
├── styles/global.css
└── consts.ts (SITE_TITLE, MAIN_NAV, RSS_CUTOFF, ANALYTICS, plural)
public/
├── uploads/ (6 картинок, перенесены из WP /wp-content/uploads/)
├── favicon.svg
├── robots.txt
├── ai.txt
└── llms.txt
nginx/pushkinohistory.ru.conf (vhost для хост-nginx, симлинкуется в /etc/nginx/conf.d/)
scripts/
convert_posts.py # WP DB → src/content/{posts,pages}.json
build-rss.js # генератор IPB-совместимого RSS (full content в CDATA)
build-sitemap.js # sitemap.xml + robots.txt
build-slugs.js # routes.json для prerender
prerender.js # SPA → статичные HTML по маршрутам через puppeteer
pull-external-rss.js # cron: внешние RSS → data/news.json (агрегатор)
public/uploads/ # картинки, перенесены из WP /wp-content/uploads/
nginx/ # vhost для прода (симлинк из /etc/nginx/conf.d/)
├── convert_posts.py (WP DB → src/content/posts.json + pages.json, fix WP-resized URL)
├── convert_to_markdown.py (posts.json → src/content/posts/*.md с frontmatter)
├── pull-external-rss.mjs (cron на хосте: feeds.json → data/news.json)
├── install-cron.sh (установка cron на web.hhivp.com)
└── package.json (изолированный fast-xml-parser)
Dockerfile (multi-stage: node:22-alpine build → nginx:1.29-alpine serve)
nginx.conf (внутри контейнера: gzip, кэш _astro/, MIME для RSS, /api/news.json из bind-mount)
docker-compose.yml (контейнер на 127.0.0.1:4146, bind-mount data/ → /var/lib/pushkino/data:ro)
.gitea/workflows/deploy.yml (push в main → SSH-деплой на web.hhivp.com)
```
## Контент
Скрейп из WP DB (`pushkinohistory_ru` на `db.hhivp.com`):
- 7 постов + 4 страницы
- 6 картинок в `public/uploads/`
- URL-encoded кириллические slugs WP → транслитерированы (`/segodnya-nochyu-rossiyane-uvidyat-pervoe/`); старые URL → 301 через `nginx/map`
- **7 постов** + **4 страницы** скрейпом из WP DB `pushkinohistory_ru` на `db.hhivp.com`
- **URL-encoded кириллические slug'и WP** → транслитерированы (`/segodnya-nochyu-rossiyane-uvidyat-pervoe/`); старые URL → 301 через nginx map по `$uri`
- **WP-resized URL** (`-1024x768.png`) → оригинал автоматически в `convert_posts.py:RESIZED_RE`
- **Frontmatter-флаги для иерархии главной:**
- `featured: true` + `featuredImage` — пин на верх как «Главная история» (Воронино, Старое Село)
- `hideFromList: true` — скрыть с главной (3 «технические работы»), доступ только через рубрику
- **Категория `main`** — псевдо-флаг «попадает на главную»; не показывается в плашках и в сайдбаре
## RSS
- **Свой `/feed/`** — IPB-совместимый RSS 2.0 с полным HTML в `<content:encoded>`, стабильными `<guid isPermaLink>`, `<dc:creator>`, категориями. Для импорта в `forum.pushkinohistory.ru`.
- **Внешние фиды** — `src/content/feeds.json` (список URL), парсятся cron-скриптом `scripts/pull-external-rss.js``data/news.json` (bind-mount), фронт читает client-side через `/api/news.json`. Каждое добавление источника = редактирование `feeds.json` + push.
### Свой `/feed.xml` (для IPB Importer)
- Полный HTML тела поста в `<content:encoded>` с CDATA (sanitize-html → buildRss)
- Стабильные `<guid isPermaLink="true">` = URL поста (IPB дедуплицирует)
- `<dc:creator>`, `<category>`, корректный `<pubDate>` в RFC-822
- Контент-Type `application/rss+xml; charset=utf-8`
- **`RSS_CUTOFF`** в `src/consts.ts` (default `2010-01-01`) — отрезает старый архив. Чтобы IPB не флудил при следующем рестарте — поменять на новую дату и пересобрать
- Per-category RSS: `/cat/<slug>/feed.xml`
### Агрегатор внешних RSS
- `src/data/feeds.json` — список источников (`enabled: false` по умолчанию)
- `scripts/pull-external-rss.mjs` — cron на web.hhivp.com (каждый час в `:12`)
- Пишет `/opt/docker/sites/pushkinohistory-ru-v2/data/news.json` (bind-mount в контейнер как `/var/lib/pushkino/data:ro`)
- Фронт `/news/` фетчит client-side через `/api/news.json` (отдаёт nginx внутри контейнера alias на bind-mount)
- Логи `/var/log/pushkino-rss-aggregator.log` + logrotate weekly × 4
Чтобы добавить источник:
1. В `src/data/feeds.json` добавить `{name, url, enabled: true, max}`
2. Push → CI → деплой
3. На следующем cron-tick'е появится в `/news/`
## Деплой
### Автоматический (Gitea Actions)
Push в `main``.gitea/workflows/deploy.yml`:
1. SSH на `web.hhivp.com` с ключом из секрета `SSH_DEPLOY_KEY`
2. `git fetch + reset --hard origin/main`
3. `docker compose build && up -d`
4. `curl -fsS http://127.0.0.1:4146/` — health check
5. `docker image prune --filter "until=168h"`
Секреты в Gitea (`/repos/striker/pushkinohistory-ru-v2/actions/secrets`):
- `SSH_DEPLOY_KEY` — приватный ключ `~/.ssh/pushkino-v2-deploy` (генерён локально, pubkey в `striker@web:~/.ssh/authorized_keys`)
- `SSH_KNOWN_HOSTS` — fingerprint `web.hhivp.com ssh-ed25519 AAAAC3...`
### Вручную
```bash
# Локально:
cd E:\Projects\pushkinohistory-ru-v2
git add . && git commit -m "..." && git push
# На web.hhivp.com:
ssh striker@web.hhivp.com
cd /opt/docker/sites/pushkinohistory-ru-v2
git pull && docker compose up -d --build
# или с локалки на сервер:
ssh striker@web.hhivp.com 'cd /opt/docker/sites/pushkinohistory-ru-v2 && git pull && docker compose up -d --build'
```
CI/CD автоматизация — Gitea Actions с SSH-deploy (см. `.gitea/workflows/deploy.yml`).
### nginx vhost
## Контейнер
Симлинк: `/etc/nginx/conf.d/pushkinohistory.ru``nginx/pushkinohistory.ru.conf` в репо. После правки nginx-конфига: push → CI заберёт → `sudo nginx -t && sudo systemctl reload nginx`.
- Image: `pushkinohistory-ru-v2:latest` (мультистейдж: builder с puppeteer/chromium → runtime node:22-alpine + express)
- Порт: `127.0.0.1:4146`
- Bind-mounts:
- `/opt/www/pushkinohistory.ru/uploads-v2 → /app/public/uploads (ro)` — динамические uploads
- `/opt/docker/sites/pushkinohistory-ru-v2/data → /app/data (ro)``news.json` от cron
- nginx vhost: `/etc/nginx/conf.d/pushkinohistory.ru` → симлинк на `nginx/pushkinohistory.ru.conf` в этом репо
301-редиректы со старых WP-URL — через `map $uri $legacy_redirect` (см. файл). Если нужно добавить новый редирект — отредактировать map и pushнуть.
## Откат на WP v1
Старый WP в `/opt/docker/sites/pushkinohistory-ru/` (контейнер `pushkinohistory-ru:4143`) сохранён. Чтобы откатиться:
Старый WP-контейнер `pushkinohistory-ru:4143` + БД `pushkinohistory_ru` на `db.hhivp.com` сохранены. Откат за ~1 минуту:
```bash
ssh striker@web.hhivp.com
sudo ln -sfn /opt/docker/sites/pushkinohistory-ru/nginx/pushkinohistory.ru.conf /etc/nginx/conf.d/pushkinohistory.ru
echo "Gh_lpx2017!" | sudo -S ln -sfn /opt/docker/sites/pushkinohistory-ru/nginx/pushkinohistory.ru.conf /etc/nginx/conf.d/pushkinohistory.ru
sudo nginx -t && sudo systemctl reload nginx
```
## БД (WP, v1)
DB на `db.hhivp.com` (45.10.53.205), `pushkinohistory_ru`/`u_pushhist`, prefix `ph_`. **Не удалять** — нужна для отката и как источник для повторного скрейпа.
После 1-2 недель стабильной работы v2 — старый WP можно удалить (контейнер + БД + репо).
## Форум
`forum.pushkinohistory.ru` — IPB 4.x в отдельном контейнере `forum-pushkinohistory-ru:4144`. **v2 редизайном не затронут.**
## Аналитика
`ANALYTICS` в `src/consts.ts` (`yandexMetrika`, `googleGtag`) пустые — впишите ID после регистрации счётчиков. Скрипты в `Analytics.astro` имеют `type="text/plain" data-cookieconsent="statistics"` и активируются только после согласия в баннере `CookieConsent.astro`.
Согласие хранится в `localStorage` + cookie `ph-consent` (12 мес). На `/privacy/` есть кнопка «Отозвать согласие».
## Локальная разработка
```bash
npm install
npm run dev # → http://localhost:4321
npm run build # → dist/ (статика)
npm run preview
```
Если 4321 занят — Astro сам найдёт следующий свободный (4322 и т.д.).
## SEO — правила для контента
**Обязательно** для каждого нового поста/страницы в `src/content/{posts,pages}/*.md`:
- `description:` — уникальное, 120160 символов. **Не оставлять пустым** — пустая строка не падает на дефолт `SITE_DESCRIPTION`, а передаётся в `<meta>` как есть.
- `[slug].astro` передаёт `description={entry.data.description ?? ''}` → BaseLayout. Если `''` — meta пустой.
**RSS-агрегатор новостей:** источники добавляются в `src/data/feeds.json` (`{name, url, enabled: true, max}`). После push → CI → на следующем cron-тике (каждый час в `:12`) появятся на `/news/`.
## История
- 2026-05-08: v1 контейнеризован, миграция со str-u-01 на web.hhivp.com
- 2026-05-14: фикс trust-proxy.conf для Docker bridges (Better WP Security)
- 2026-05-21: v2 редизайн — Vite+React+Tailwind, отказ от WP, RSS-агрегатор внешних фидов + свой RSS для IPB
- **2026-05-08:** v1 (WordPress 6.x) контейнеризован, миграция со str-u-01 на web.hhivp.com
- **2026-05-14:** фикс trust-proxy.conf для Docker bridges (Better WP Security)
- **2026-05-21:** v2 редизайн — Vite+React → Astro 5 (стек как у `anotherreflections-website-v2`). Cutover, бэкап старого WP в репо `pushkinohistory-ru` + БД на `db.hhivp.com` (~2 недели на наблюдение).
- **2026-05-30:** заполнены descriptions всем 11 страницам/постам (были пустыми → дубли в Я.Вебмастере).

View File

@@ -1,40 +1,27 @@
# syntax=docker/dockerfile:1
# ─── Stage 1: build SPA + prerender via puppeteer ──────────────────────────
FROM node:22-alpine AS builder
# ─── 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 install --no-audit --no-fund
RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont
ENV PUPPETEER_SKIP_DOWNLOAD=true \
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
COPY . .
RUN npm run build:prerender
RUN npm run build
# ─── Stage 2: runtime (Express) ─────────────────────────────────────────────
FROM node:22-alpine
WORKDIR /app
# ─── Stage 2: nginx runtime ─────────────────────────────────────────────────
FROM nginx:1.29-alpine
ENV NODE_ENV=production \
PORT=3000
RUN rm -rf /usr/share/nginx/html/*
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY package.json package-lock.json* ./
RUN npm install --omit=dev --no-audit --no-fund && npm cache clean --force
# Каталог для bind-mounted news.json от хостового cron-агрегатора
RUN mkdir -p /var/lib/pushkino/data
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/server ./server
COPY --from=builder /app/scripts/pull-external-rss.js ./scripts/pull-external-rss.js
COPY --from=builder /app/src/content/feeds.json ./src/content/feeds.json
EXPOSE 80
RUN mkdir -p /app/public/uploads /app/data && chown -R node:node /app/public /app/data
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget --quiet --tries=1 --spider http://127.0.0.1:3000/api/health || exit 1
USER node
CMD ["node", "server/index.js"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget -q --spider http://127.0.0.1/ || exit 1

54
README.md Normal file
View File

@@ -0,0 +1,54 @@
# pushkinohistory.ru
Сайт «История города Пушкино» — портал с историческими материалами, новостями и агрегатором региональных RSS-источников.
**Production:** https://pushkinohistory.ru
> **Mirror.** Source-of-truth: `git.striker.su/striker/pushkinohistory-ru-v2`. Этот репозиторий на GitHub — резервная копия + площадка для GitHub Actions security-сканов.
## Стек
- **Astro 5** + Content Collections + markdown
- **nginx:1.29-alpine** в runtime-контейнере
- **PT Serif** (заголовки/тело статьи) + **IBM Plex Sans** (UI)
- **sanitize-html** — очистка тела поста для RSS `<content:encoded>` с CDATA
- **fast-xml-parser** — изолированный cron-агрегатор внешних RSS
## Деплой
`git push` → Gitea Actions (`.gitea/workflows/deploy.yml`) → SSH на `web.hhivp.com`:
1. `git pull --ff-only` в `/opt/docker/sites/pushkinohistory-ru-v2`
2. `docker compose build`
3. **Trivy** scan свежесобранного образа (HIGH/CRITICAL, warning-only)
4. `docker compose up -d` → healthcheck `http://127.0.0.1:4146/`
5. **IndexNow** ping Яндекс/Bing (sitemap-based)
6. `docker image prune -af --filter "until=168h"`
Контейнер `pushkinohistory-ru-v2:4146` за хостовым nginx.
## Security
Все автоматические проверки описаны в [`SECURITY.md`](SECURITY.md):
- **Layer A** (на каждый push): Trivy + npm audit
- **Layer B** (weekly cron на rd.hhivp.com): Nuclei DAST
- **Layer C** (на push + MR): Hadolint + GitLeaks + Semgrep
## Локальная разработка
```bash
npm install
npm run dev # http://localhost:4321
npm run build # → dist/ (статика)
npm run preview
```
## История
- **2026-05-21:** v2 — Astro 5 + Content Collections.
- **2026-05-23:** WordPress v1 контейнер удалён, бэкапы в `/opt/backup/`.
## Контакты
- Email: admin@hhivp.com
- Site: https://hhivp.com/

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/

16
astro.config.mjs Normal file
View File

@@ -0,0 +1,16 @@
// @ts-check
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://pushkinohistory.ru',
trailingSlash: 'always',
build: {
format: 'directory',
},
integrations: [
sitemap({
filter: (page) => !page.includes('/feed') && !page.includes('/api/'),
}),
],
});

View File

@@ -6,21 +6,20 @@ services:
container_name: pushkinohistory-ru-v2
restart: unless-stopped
ports:
- "127.0.0.1:4146:3000"
environment:
NODE_ENV: production
PORT: 3000
- "127.0.0.1:4146:80"
volumes:
- /opt/www/pushkinohistory.ru/uploads:/app/public/uploads:ro
- /opt/docker/sites/pushkinohistory-ru-v2/data:/app/data:ro
# cron на хосте пишет news.json для агрегатора внешних RSS
- /opt/docker/sites/pushkinohistory-ru-v2/data:/var/lib/pushkino/data:ro
cap_drop: [ALL]
cap_add: [NET_BIND_SERVICE, CHOWN, SETUID, SETGID]
cap_add: [NET_BIND_SERVICE, CHOWN, SETUID, SETGID, DAC_OVERRIDE]
security_opt: [no-new-privileges:true]
tmpfs:
- /tmp:noexec,nosuid,size=16m
- /var/cache/nginx:size=32m
- /var/run:size=4m
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:3000/api/health"]
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
start_period: 10s

View File

@@ -1,15 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>История города Пушкино</title>
<meta name="description" content="История города Пушкино, фотографии, новости и форум." />
<link rel="alternate" type="application/rss+xml" title="История города Пушкино — RSS" href="/feed/" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

91
nginx.conf Normal file
View File

@@ -0,0 +1,91 @@
# nginx-конфиг внутри контейнера. TLS терминируется на хосте (web.hhivp.com).
server {
listen 80 default_server;
server_name _;
root /usr/share/nginx/html;
index index.html;
access_log /dev/stdout;
error_log /dev/stderr warn;
location ~ /\.(?!well-known) {
deny all;
log_not_found off;
access_log off;
}
gzip on;
gzip_vary on;
gzip_min_length 256;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
text/plain text/css text/xml
application/json application/xml application/rss+xml application/atom+xml
application/javascript application/x-javascript
image/svg+xml font/woff2;
# RSS-фиды: явный Content-Type для IPB RSS Importer
location = /feed.xml {
types { } default_type application/rss+xml;
charset utf-8;
try_files /feed.xml =404;
expires 5m;
add_header Cache-Control "public, max-age=300, must-revalidate";
}
location ~ ^/cat/[^/]+/feed\.xml$ {
types { } default_type application/rss+xml;
charset utf-8;
expires 5m;
add_header Cache-Control "public, max-age=300, must-revalidate";
}
location = /sitemap.txt { default_type text/plain; charset utf-8; }
location = /robots.txt { default_type text/plain; charset utf-8; }
location = /ai.txt { default_type text/plain; charset utf-8; }
location = /llms.txt { default_type text/plain; charset utf-8; }
# Агрегатор новостей: cron на хосте пишет в /var/lib/pushkino/data/news.json (bind-mount)
location = /api/news.json {
alias /var/lib/pushkino/data/news.json;
default_type application/json;
charset utf-8;
add_header Cache-Control "public, max-age=120, must-revalidate";
# Если файл не создан cron'ом — отдадим пустую структуру
try_files $uri @news_fallback;
}
location @news_fallback {
default_type application/json;
return 200 '{"updatedAt":null,"items":[]}';
}
# Кэш статических ассетов Astro (хеш в имени)
location /_astro/ {
expires 1y;
add_header Cache-Control "public, immutable, max-age=31536000";
access_log off;
}
location /uploads/ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
access_log off;
}
location ~* \.(?:css|js|woff2?|ttf|otf|eot)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
access_log off;
}
location ~* \.(?:png|jpe?g|gif|svg|webp|avif|ico)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
access_log off;
}
# Маршрутизация: Astro генерит dir/index.html
location / {
try_files $uri $uri/ $uri.html =404;
}
error_page 404 /404.html;
location = /404.html { internal; }
}

View File

@@ -1,15 +1,19 @@
# pushkinohistory.ru — Vite+React (v2)
# Container: pushkinohistory-ru-v2 on 127.0.0.1:4146
# v2 cutover: 2026-05-21 (старый WP на :4143 оставлен в /opt/docker/sites/pushkinohistory-ru как backup)
# pushkinohistory.ru — Astro v2
# Container: pushkinohistory-ru-v2 (nginx:alpine + Astro SSG) on 127.0.0.1:4146
# v2 cutover: 2026-05-21 (старый WP на :4143 оставлен в /opt/docker/sites/pushkinohistory-ru как backup для отката)
# 301-редиректы со старых URL-encoded WP slugs (cyrillic) на новые транслитерированные.
# nginx уже декодирует URI до cyrillic'а, поэтому в ключах map'а — кириллица в UTF-8.
map $request_uri $legacy_redirect {
# 301-редиректы со старых URL-encoded WP-cyrillic slugs на новые транслитерированные.
# $uri — URL-декодированный путь (UTF-8 cyrillic). $request_uri сохраняет percent-encoding,
# поэтому для cyrillic-slugs нужен именно $uri.
map $uri $legacy_redirect {
default "";
~^/добро-пожаловать/?$ /dobro-pozhalovat/;
~^/фото/?$ /foto/;
~^/сегодня-ночью-россияне-увидят-первое-суперлуние-года-волчью-луну/?$ /segodnya-nochyu-rossiyane-uvidyat-pervoe/;
~^/первые-20-градусные-морозы/?$ /pervye-20-gradusnye-morozy/;
# WP feed → новый Astro feed
~^/feed/?$ /feed.xml;
~^/feed/rss2/?$ /feed.xml;
}
server {
@@ -59,39 +63,18 @@ server {
client_max_body_size 4M;
# 301-редиректы с легаси WP-slugs на новые транслитерированные пути
# 301-редиректы с легаси WP-URL
if ($legacy_redirect != "") {
return 301 $legacy_redirect;
}
# WP-эндпоинты больше не существуют, отдаём 410 Gone (помогает поисковикам пометить как удалённые)
location ~* ^/(wp-admin|wp-login\.php|wp-content|wp-includes|xmlrpc\.php|wp-cron\.php|wp-config\.php|readme\.html)$ {
# WP-эндпоинты больше не существуют 410 Gone (поисковики помечают как удалённые)
location ~* ^/(wp-admin|wp-login\.php|wp-content|wp-includes|xmlrpc\.php|wp-cron\.php|wp-config\.php|readme\.html) {
return 410;
}
# RSS-фид (статичный файл, отдаётся из dist)
location = /feed/ {
proxy_pass http://127.0.0.1:4146;
include /etc/nginx/templates/proxy.conf;
add_header Content-Type "application/rss+xml; charset=utf-8" always;
add_header Cache-Control "public, max-age=600" always;
}
# Агрегатор новостей: апдейтится по cron, кешируем коротко
location = /api/news.json {
proxy_pass http://127.0.0.1:4146;
include /etc/nginx/templates/proxy.conf;
add_header Cache-Control "public, max-age=120" always;
}
# Картинки/статические ассеты — кешируем подольше
location ~* ^/(uploads|assets)/ {
proxy_pass http://127.0.0.1:4146;
include /etc/nginx/templates/proxy.conf;
add_header Cache-Control "public, max-age=604800, immutable" always;
proxy_cache_valid 200 7d;
}
# Всё остальное — на контейнер v2. Astro nginx внутри сам разруливает кэши,
# MIME для RSS, /api/news.json из bind-mount и т.д.
location / {
proxy_pass http://127.0.0.1:4146;
include /etc/nginx/templates/proxy.conf;

6983
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,33 +1,28 @@
{
"name": "pushkinohistory-ru-v2",
"version": "0.1.0",
"description": "История города Пушкино — статический сайт + RSS",
"private": true,
"type": "module",
"version": "0.2.0",
"private": true,
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "vite build && node scripts/build-slugs.js && node scripts/build-sitemap.js && node scripts/build-rss.js",
"build:prerender": "vite build && node scripts/build-slugs.js && node scripts/build-sitemap.js && node scripts/build-rss.js && node scripts/prerender.js",
"prerender": "node scripts/prerender.js",
"preview": "vite preview",
"start": "node server/index.js",
"pull-rss": "node scripts/pull-external-rss.js"
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"indexnow": "node scripts/indexnow.js"
},
"dependencies": {
"@fontsource/pt-serif": "^5.2.5",
"@astrojs/rss": "^4.0.12",
"@astrojs/sitemap": "^3.6.0",
"@fontsource/ibm-plex-sans": "^5.2.5",
"compression": "^1.7.5",
"express": "^4.21.2",
"fast-xml-parser": "^4.5.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"@fontsource/pt-serif": "^5.2.5",
"astro": "^6.3.6",
"sanitize-html": "^2.17.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"puppeteer": "^23.10.4",
"tailwindcss": "^3.4.17",
"vite": "^6.0.5"
"@types/sanitize-html": "^2.16.0",
"sharp": "^0.34.5"
}
}

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,6 @@
Contact: mailto:admin@pushkinohistory.ru
Contact: https://pushkinohistory.ru/privacy/
Expires: 2027-05-21T00:00:00.000Z
Preferred-Languages: ru, en
Canonical: https://pushkinohistory.ru/.well-known/security.txt
Policy: https://pushkinohistory.ru/privacy/

View File

@@ -0,0 +1 @@
9018cf11050b4f379b8cec01ae3239bb

12
public/ai.txt Normal file
View File

@@ -0,0 +1,12 @@
# ai.txt — Spawning.ai opt-in
# https://site.spawning.ai/spawning/ai-txt
User-Agent: *
Allow: /
Train: yes
Cite: yes
Quote: yes
Image: yes
Contact: info@pushkinohistory.ru

5
public/favicon.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="3" fill="#f4ecdb"/>
<text x="50%" y="62%" font-family="Georgia, 'PT Serif', serif" font-size="20" font-weight="700"
text-anchor="middle" fill="#8a3a14">П</text>
</svg>

After

Width:  |  Height:  |  Size: 271 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

10
public/humans.txt Normal file
View File

@@ -0,0 +1,10 @@
/* SITE */
Site: История города Пушкино — pushkinohistory.ru
Last update: 2026-05-21
Standards: HTML5, CSS3, RSS 2.0
Components: Astro 6, nginx 1.29
Software: Docker, Gitea Actions
/* TEAM */
Maintained by: HHIVP
Contact: admin@pushkinohistory.ru

30
public/llms.txt Normal file
View File

@@ -0,0 +1,30 @@
# История города Пушкино
> Краеведческий сайт, посвящённый истории города Пушкино (Московская область).
> Материалы 20102026 годов: исторические статьи, фотографии, новости.
> Связан с форумом forum.pushkinohistory.ru (IPB).
## Ключевые страницы
- [Главная — лента публикаций](https://pushkinohistory.ru/)
- [История](https://pushkinohistory.ru/history/)
- [Фото](https://pushkinohistory.ru/foto/)
## Краеведческие статьи
- [Воронино — история села](https://pushkinohistory.ru/voronino/)
- [Старое, Старое Село](https://pushkinohistory.ru/staroe-staroe-selo/)
## RSS-фиды
- [Общий RSS](https://pushkinohistory.ru/feed.xml)
- [Карта сайта (txt)](https://pushkinohistory.ru/sitemap.txt)
- [Карта сайта (XML)](https://pushkinohistory.ru/sitemap-index.xml)
## Внешние ресурсы
- [Форум — обсуждения](https://forum.pushkinohistory.ru/)
## Контакты
- email: info@pushkinohistory.ru

26
public/robots.txt Normal file
View File

@@ -0,0 +1,26 @@
User-agent: *
Allow: /
# AI-crawlers
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: anthropic-ai
Allow: /
User-agent: Google-Extended
Allow: /
User-agent: CCBot
Allow: /
User-agent: PerplexityBot
Allow: /
Host: pushkinohistory.ru
Sitemap: https://pushkinohistory.ru/sitemap-index.xml
Sitemap: https://pushkinohistory.ru/sitemap.txt

View File

@@ -34,9 +34,13 @@ def slugify_ru(s: str) -> str:
return res or 'untitled'
UPLOAD_RE = re.compile(r'https?://(?:www\.)?pushkinohistory\.ru/wp-content/uploads/[^/]+/[^/]+/([^"\'\s)]+)')
# WP-resized варианты: file-1024x768.png → file.png. У нас в /uploads/ лежит только оригинал.
RESIZED_RE = re.compile(r'(/uploads/[^"\'\s)]+?)-\d+x\d+(\.\w+)')
def rewrite_uploads(html: str) -> str:
return UPLOAD_RE.sub(r'/uploads/\1', html)
html = UPLOAD_RE.sub(r'/uploads/\1', html)
html = RESIZED_RE.sub(r'\1\2', html)
return html
CATEGORIES = {
20: [], 23: [], 73: [], 94: [], # pages — no category

View File

@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Convert src/content/{posts,pages}.json → src/content/{posts,pages}/<slug>.md
for Astro Content Collections."""
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
CONTENT = ROOT / "src" / "content"
POSTS_JSON = CONTENT / "posts.json"
PAGES_JSON = CONTENT / "pages.json"
POSTS_DIR = CONTENT / "posts"
PAGES_DIR = CONTENT / "pages"
POSTS_DIR.mkdir(parents=True, exist_ok=True)
PAGES_DIR.mkdir(parents=True, exist_ok=True)
# Ручные флаги по slug'у для главной ленты.
# featured=True — пинится наверх как hero (отдельная карточка с рамкой).
# hideFromList=True — не показывается в общей ленте (виден только в рубрике/архиве).
# featuredImage — переопределяет первую <img> из тела для миниатюры/hero.
SLUG_FLAGS: dict[str, dict] = {
'voronino': {'featured': True, 'featuredImage': '/uploads/IMG_2156.jpg'},
'staroe-staroe-selo': {'featured': True, 'featuredImage': '/uploads/IMG_2754.jpg'},
'vnimanie-texnicheskie-raboty': {'hideFromList': True},
'vnimanie-texnicheskie-raboty-2': {'hideFromList': True},
'c-nastupayushhim-novym-2014-godom': {'hideFromList': True},
}
def yaml_escape(s: str) -> str:
return s.replace('"', '\\"')
def make_md(item: dict, kind: str) -> str:
fm = [
'---',
f'title: "{yaml_escape(item["title"])}"',
f'slug: {item["slug"]}',
f'legacyId: {item["id"]}',
]
if item.get('date'):
date = item['date'].replace(' ', 'T') + '+03:00'
if kind == 'post':
fm.append(f'pubDate: {date}')
else:
fm.append(f'pubDate: {date}')
if item.get('excerpt'):
fm.append(f'description: "{yaml_escape(item["excerpt"])}"')
else:
fm.append('description: ""')
if kind == 'post':
if item.get('categories'):
fm.append('categories:')
for c in item['categories']:
fm.append(f' - "{yaml_escape(c)}"')
else:
fm.append('categories: []')
if item.get('categorySlugs'):
fm.append('categorySlugs:')
for s in item['categorySlugs']:
fm.append(f' - "{s}"')
else:
fm.append('categorySlugs: []')
fm.append('author: "История города Пушкино"')
if item.get('oldSlug') and item['oldSlug'] != item['slug']:
fm.append(f'oldSlug: "{item["oldSlug"]}"')
if kind == 'post':
flags = SLUG_FLAGS.get(item['slug'], {})
if flags.get('featured'):
fm.append('featured: true')
if flags.get('hideFromList'):
fm.append('hideFromList: true')
if flags.get('featuredImage'):
fm.append(f'featuredImage: "{flags["featuredImage"]}"')
fm.append('---')
fm.append('')
fm.append(item['html'])
return '\n'.join(fm) + '\n'
def convert(json_path: Path, out_dir: Path, kind: str) -> int:
items = json.loads(json_path.read_text(encoding='utf-8'))
for item in items:
md = make_md(item, kind)
(out_dir / f'{item["slug"]}.md').write_text(md, encoding='utf-8')
return len(items)
if __name__ == '__main__':
n_posts = convert(POSTS_JSON, POSTS_DIR, 'post')
n_pages = convert(PAGES_JSON, PAGES_DIR, 'page')
print(f'posts: {n_posts} → src/content/posts/')
print(f'pages: {n_pages} → src/content/pages/')

120
scripts/indexnow.js Normal file
View File

@@ -0,0 +1,120 @@
// IndexNow: уведомить Yandex/Bing о новых/обновлённых URL.
// Запускается после деплоя из CI или вручную:
// node scripts/indexnow.js
//
// IndexNow ключ хранится в файле public/<key>.txt — он должен быть доступен по
// тому же URL что и сайт, чтобы поисковики могли подтвердить ownership.
//
// Источник URL (по приоритету):
// 1) dist/sitemap-index.xml (Astro @astrojs/sitemap) + входящие sitemap-*.xml
// 2) dist/sitemap.txt / dist/sitemap.xml
// 3) fetch с прод-сайта: https://pushkinohistory.ru/sitemap-index.xml
// (для запуска из CI после удалённого деплоя — без локального build)
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const root = path.resolve(__dirname, '..');
const BASE = 'https://pushkinohistory.ru';
const HOST = 'pushkinohistory.ru';
const KEY = '9018cf11050b4f379b8cec01ae3239bb';
// На случай если кто-то удалит файл — пересоздадим автоматически.
const keyFile = path.join(root, 'public', `${KEY}.txt`);
if (!fs.existsSync(keyFile)) {
fs.writeFileSync(keyFile, KEY, 'utf-8');
console.log(`created key file: public/${KEY}.txt`);
}
function extractLocs(xml) {
return [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1].trim());
}
let urls = [];
const distDir = path.join(root, 'dist');
const sitemapIndex = path.join(distDir, 'sitemap-index.xml');
const sitemapTxt = path.join(distDir, 'sitemap.txt');
const fallbackTxt = path.join(distDir, 'sitemap.xml');
if (fs.existsSync(sitemapIndex)) {
const idx = fs.readFileSync(sitemapIndex, 'utf-8');
const subs = extractLocs(idx);
for (const sub of subs) {
// sub — абсолютный URL вида https://pushkinohistory.ru/sitemap-0.xml
const fname = sub.split('/').pop();
const localPath = path.join(distDir, fname);
if (fs.existsSync(localPath)) {
const subXml = fs.readFileSync(localPath, 'utf-8');
urls.push(...extractLocs(subXml));
} else {
console.warn(` sub-sitemap not found locally: ${localPath}`);
}
}
} else if (fs.existsSync(sitemapTxt)) {
urls = fs
.readFileSync(sitemapTxt, 'utf-8')
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l.startsWith('http'));
} else if (fs.existsSync(fallbackTxt)) {
const xml = fs.readFileSync(fallbackTxt, 'utf-8');
urls = extractLocs(xml);
} else {
console.log('no local sitemap found, fetching from production…');
try {
const idxResp = await fetch(`${BASE}/sitemap-index.xml`);
if (!idxResp.ok) throw new Error(`HTTP ${idxResp.status}`);
const idxXml = await idxResp.text();
const subs = extractLocs(idxXml);
for (const sub of subs) {
const subResp = await fetch(sub);
if (!subResp.ok) {
console.warn(` ${sub}: HTTP ${subResp.status}`);
continue;
}
const subXml = await subResp.text();
urls.push(...extractLocs(subXml));
}
} catch (e) {
console.error(`failed to fetch sitemap from ${BASE}: ${e.message}`);
process.exit(1);
}
}
// Дедупликация и фильтрация на всякий случай.
urls = [...new Set(urls)].filter((u) => u.startsWith(BASE));
if (urls.length === 0) {
console.error('no URLs collected — nothing to submit');
process.exit(1);
}
console.log(`Submitting ${urls.length} URLs to IndexNow…`);
const payload = {
host: HOST,
key: KEY,
keyLocation: `${BASE}/${KEY}.txt`,
urlList: urls,
};
async function submit(endpoint) {
try {
const r = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify(payload),
});
console.log(` ${endpoint}: HTTP ${r.status}`);
} catch (e) {
console.error(` ${endpoint}: ${e.message}`);
}
}
await Promise.all([
submit('https://yandex.com/indexnow'),
submit('https://api.indexnow.org/indexnow'),
]);

42
scripts/install-cron.sh Normal file
View File

@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Установка cron-агрегатора внешних RSS на web.hhivp.com.
# Запуск: sudo bash scripts/install-cron.sh
set -euo pipefail
DEPLOY_PATH="${DEPLOY_PATH:-/opt/docker/sites/pushkinohistory-ru-v2}"
DATA_DIR="$DEPLOY_PATH/data"
CRON_FILE="/etc/cron.d/pushkino-rss-aggregator"
LOG_FILE="/var/log/pushkino-rss-aggregator.log"
cd "$DEPLOY_PATH/scripts"
echo "==> installing fast-xml-parser into $DEPLOY_PATH/scripts/node_modules"
sudo -u striker npm install --no-audit --no-fund --omit=dev 2>&1 | tail -3
mkdir -p "$DATA_DIR"
chown striker:docker "$DATA_DIR"
chmod 775 "$DATA_DIR"
cat > "$CRON_FILE" <<EOF
# Pulls external RSS feeds → $DATA_DIR/news.json
# Bind-mounted в контейнер pushkinohistory-ru-v2 как /var/lib/pushkino/data:ro
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# каждый час в 12-ю минуту
12 * * * * striker cd $DEPLOY_PATH && /usr/bin/node scripts/pull-external-rss.mjs >> $LOG_FILE 2>&1
EOF
chmod 644 "$CRON_FILE"
cat > /etc/logrotate.d/pushkino-rss-aggregator <<EOF
$LOG_FILE {
weekly
rotate 4
compress
delaycompress
missingok
notifempty
copytruncate
}
EOF
systemctl reload cron
echo "==> done. cron file: $CRON_FILE | log: $LOG_FILE"

9
scripts/package.json Normal file
View File

@@ -0,0 +1,9 @@
{
"name": "pushkinohistory-cron",
"type": "module",
"private": true,
"description": "Standalone cron-скрипт для агрегации внешних RSS. Изолирован от основного app package.json.",
"dependencies": {
"fast-xml-parser": "^4.5.0"
}
}

View File

@@ -0,0 +1,124 @@
#!/usr/bin/env node
/**
* Тянет внешние RSS-фиды из src/data/feeds.json и записывает агрегированный
* news.json в DATA_DIR (по умолчанию ./data). Запускается по cron на хосте.
*
* Использование:
* node scripts/pull-external-rss.mjs # пишет в ./data/news.json
* DATA_DIR=/abs/path node scripts/pull-external-rss.mjs
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { XMLParser } from 'fast-xml-parser';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const FEEDS_FILE = path.join(ROOT, 'src', 'data', 'feeds.json');
const DATA_DIR = process.env.DATA_DIR || path.join(ROOT, 'data');
const OUT_FILE = path.join(DATA_DIR, 'news.json');
const TIMEOUT_MS = 15000;
const HARD_CAP = 200;
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
textNodeName: '#text',
});
async function fetchFeed(url, timeoutMs) {
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), timeoutMs);
try {
const r = await fetch(url, {
signal: ctl.signal,
headers: { 'User-Agent': 'pushkinohistory-ru-v2 RSS aggregator' },
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return await r.text();
} finally {
clearTimeout(t);
}
}
function stripHtml(s) {
if (!s) return '';
return String(s).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 400);
}
function extractItems(xml, feed) {
const parsed = parser.parse(xml);
const rssItems = parsed?.rss?.channel?.item;
if (rssItems) {
const arr = Array.isArray(rssItems) ? rssItems : [rssItems];
return arr.map((it) => ({
title: typeof it.title === 'string' ? it.title : it.title?.['#text'] || '',
link: typeof it.link === 'string' ? it.link : it.link?.['#text'] || '',
guid: typeof it.guid === 'string' ? it.guid : it.guid?.['#text'] || it.link || '',
pubDate: it.pubDate ? new Date(it.pubDate).toISOString() : null,
description: stripHtml(it.description || it['content:encoded'] || ''),
source: feed.name,
}));
}
const atomEntries = parsed?.feed?.entry;
if (atomEntries) {
const arr = Array.isArray(atomEntries) ? atomEntries : [atomEntries];
return arr.map((e) => {
const link = Array.isArray(e.link)
? e.link[0]?.['@_href']
: e.link?.['@_href'] || e.link;
return {
title: typeof e.title === 'string' ? e.title : e.title?.['#text'] || '',
link: link || '',
guid: e.id || link || '',
pubDate: e.updated || e.published ? new Date(e.updated || e.published).toISOString() : null,
description: stripHtml(e.summary?.['#text'] || e.summary || e.content?.['#text'] || ''),
source: feed.name,
};
});
}
return [];
}
async function main() {
const feeds = JSON.parse(fs.readFileSync(FEEDS_FILE, 'utf8')).filter((f) => f.enabled);
if (feeds.length === 0) {
console.log('no enabled feeds — writing empty news.json');
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(OUT_FILE, JSON.stringify({ updatedAt: new Date().toISOString(), items: [] }, null, 2));
return;
}
const all = [];
for (const feed of feeds) {
try {
const xml = await fetchFeed(feed.url, TIMEOUT_MS);
const items = extractItems(xml, feed);
const max = feed.max || 20;
all.push(...items.slice(0, max));
console.log(`OK ${feed.name}: ${items.length} (kept ${Math.min(items.length, max)})`);
} catch (e) {
console.warn(`FAIL ${feed.name}: ${e.message}`);
}
}
const seen = new Set();
const deduped = [];
for (const it of all) {
const key = it.guid || it.link;
if (!key || seen.has(key)) continue;
seen.add(key);
deduped.push(it);
}
deduped.sort((a, b) => (b.pubDate || '').localeCompare(a.pubDate || ''));
const out = { updatedAt: new Date().toISOString(), items: deduped.slice(0, HARD_CAP) };
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(OUT_FILE, JSON.stringify(out, null, 2));
console.log(`-> ${OUT_FILE}: ${out.items.length} items`);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -1,56 +0,0 @@
import express from 'express';
import compression from 'compression';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const DIST = path.join(ROOT, 'dist');
const DATA = path.join(ROOT, 'data');
const UPLOADS = path.join(ROOT, 'public', 'uploads');
const NEWS_FILE = path.join(DATA, 'news.json');
const PORT = Number(process.env.PORT) || 3000;
const app = express();
app.disable('x-powered-by');
app.use(compression());
app.get('/api/health', (_req, res) => res.json({ ok: true, ts: Date.now() }));
app.get('/api/news.json', (_req, res) => {
try {
if (!fs.existsSync(NEWS_FILE)) {
return res.json({ updatedAt: null, items: [] });
}
const stat = fs.statSync(NEWS_FILE);
res.setHeader('Cache-Control', 'public, max-age=300');
res.setHeader('Last-Modified', stat.mtime.toUTCString());
res.sendFile(NEWS_FILE);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.use('/uploads', express.static(UPLOADS, { maxAge: '7d', fallthrough: true }));
const FEED_FILE = path.join(DIST, 'feed.xml');
app.get(['/feed', '/feed/', '/feed/rss2', '/feed/rss2/', '/rss', '/rss.xml'], (_req, res) => {
res.type('application/rss+xml; charset=utf-8');
res.sendFile(FEED_FILE);
});
app.use(express.static(DIST, { maxAge: '1h', etag: true }));
app.get('*', (req, res) => {
const candidate = path.join(DIST, req.path, 'index.html');
if (fs.existsSync(candidate)) {
return res.sendFile(candidate);
}
res.status(200).sendFile(path.join(DIST, 'index.html'));
});
app.listen(PORT, () => {
console.log(`pushkinohistory-ru-v2 listening on :${PORT}`);
});

View File

@@ -1,133 +0,0 @@
import React, { useEffect, useState } from 'react';
import Header from './components/Header';
import Sidebar from './components/Sidebar';
import Footer from './components/Footer';
import Home from './pages/Home';
import Post from './pages/Post';
import Page from './pages/Page';
import Category from './pages/Category';
import News from './pages/News';
import NotFound from './pages/NotFound';
import { pages, posts, oldSlugRedirects } from './content';
export function navigate(path) {
window.history.pushState({}, '', path);
window.dispatchEvent(new PopStateEvent('popstate'));
}
function resolveRoute(pathname) {
const path = pathname.endsWith('/') || pathname.includes('.') ? pathname : pathname + '/';
if (oldSlugRedirects[path]) {
if (typeof window !== 'undefined') {
window.history.replaceState({}, '', oldSlugRedirects[path]);
}
return resolveRoute(oldSlugRedirects[path]);
}
if (path === '/') return { type: 'home' };
if (path === '/news/') return { type: 'news' };
const cat = path.match(/^\/cat\/([^/]+)\/$/);
if (cat) return { type: 'category', slug: decodeURIComponent(cat[1]) };
const seg = path.match(/^\/([^/]+)\/$/);
if (seg) {
const slug = decodeURIComponent(seg[1]);
if (pages[slug]) return { type: 'page', slug };
if (posts[slug]) return { type: 'post', slug };
}
return { type: 'notfound' };
}
function setMeta(name, content) {
if (typeof document === 'undefined') return;
let tag = document.querySelector(`meta[name="${name}"]`);
if (!tag) {
tag = document.createElement('meta');
tag.setAttribute('name', name);
document.head.appendChild(tag);
}
tag.setAttribute('content', content);
}
function setTitle(title) {
if (typeof document !== 'undefined') document.title = title;
}
export default function App() {
const [path, setPath] = useState(
typeof window !== 'undefined' ? window.location.pathname : '/'
);
useEffect(() => {
const onPop = () => setPath(window.location.pathname);
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
useEffect(() => {
const onClick = (e) => {
const a = e.target.closest('a');
if (!a) return;
const href = a.getAttribute('href');
if (!href || href.startsWith('http') || href.startsWith('//') || href.startsWith('#') || href.startsWith('mailto:') || a.target === '_blank') return;
e.preventDefault();
navigate(href);
};
document.addEventListener('click', onClick);
return () => document.removeEventListener('click', onClick);
}, []);
const route = resolveRoute(path);
const SITE_NAME = 'История города Пушкино';
const SITE_DESC = 'Прошлое, настоящее, будущее города Пушкино: история, фото, новости, форум.';
let main = null;
let title = SITE_NAME;
let desc = SITE_DESC;
if (route.type === 'home') {
main = <Home />;
} else if (route.type === 'news') {
main = <News />;
title = `Новости — ${SITE_NAME}`;
} else if (route.type === 'post') {
const p = posts[route.slug];
main = <Post post={p} />;
title = `${p.title}${SITE_NAME}`;
desc = p.excerpt || p.title;
} else if (route.type === 'page') {
const p = pages[route.slug];
main = <Page page={p} />;
title = `${p.title}${SITE_NAME}`;
desc = p.excerpt || p.title;
} else if (route.type === 'category') {
main = <Category slug={route.slug} />;
title = `Категория — ${SITE_NAME}`;
} else {
main = <NotFound />;
title = `Не найдено — ${SITE_NAME}`;
}
useEffect(() => {
setTitle(title);
setMeta('description', desc);
}, [title, desc]);
useEffect(() => {
window.scrollTo(0, 0);
}, [path]);
return (
<div className="min-h-screen flex flex-col">
<Header />
<div className="max-w-6xl mx-auto w-full px-4 sm:px-6 py-6 grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-8 flex-grow">
<main className="min-w-0">{main}</main>
<Sidebar />
</div>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,26 @@
---
import { ANALYTICS } from '../consts';
const ym = ANALYTICS.yandexMetrika;
const ga = ANALYTICS.googleGtag;
---
{ym && (
<script type="text/plain" data-cookieconsent="statistics" is:inline define:vars={{ ym }}>
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})
(window,document,"script","https://mc.yandex.ru/metrika/tag.js","ym");
window.ym(ym, "init", { clickmap:true, trackLinks:true, accurateTrackBounce:true, webvisor:true });
</script>
)}
{ga && (
<script type="text/plain" data-cookieconsent="statistics" is:inline src={`https://www.googletagmanager.com/gtag/js?id=${ga}`}></script>
)}
{ga && (
<script type="text/plain" data-cookieconsent="statistics" is:inline define:vars={{ ga }}>
window.dataLayer = window.dataLayer || [];
function gtag(){ dataLayer.push(arguments); }
gtag('js', new Date());
gtag('config', ga);
</script>
)}

View File

@@ -0,0 +1,101 @@
---
// 152-ФЗ cookie consent baner. Хранит выбор в localStorage + cookie ph-consent.
// Активирует скрипты Analytics при согласии (см. Analytics.astro).
---
<div id="cookie-consent" hidden role="dialog" aria-labelledby="cc-title" aria-describedby="cc-desc">
<div class="cc-inner">
<div class="cc-text">
<strong id="cc-title">Мы используем cookies</strong>
<p id="cc-desc">
Сайт использует cookies и системы аналитики (Яндекс.Метрика, Google Analytics) для
анонимной статистики посещений. Подробнее — в <a href="/privacy/">политике конфиденциальности</a>.
</p>
</div>
<div class="cc-buttons">
<button type="button" id="cc-deny" class="cc-btn cc-btn-secondary">Отклонить</button>
<button type="button" id="cc-accept" class="cc-btn cc-btn-primary">Принять</button>
</div>
</div>
</div>
<style>
#cookie-consent {
position: fixed;
left: 1rem;
right: 1rem;
bottom: 1rem;
z-index: 1000;
background: var(--paper);
border: 1px solid var(--rule-strong);
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
padding: 1rem 1.25rem;
max-width: 720px;
margin: 0 auto;
font-family: var(--font-sans);
}
.cc-inner { display: flex; flex-direction: column; gap: 0.75rem; }
@media (min-width: 640px) {
.cc-inner { flex-direction: row; align-items: center; }
}
.cc-text strong { font-family: var(--font-serif); font-size: 1.05rem; }
.cc-text p { margin: 0.3rem 0 0; font-size: 0.88rem; color: var(--ink-soft); line-height: 1.5; }
.cc-buttons { display: flex; gap: 0.5rem; flex-shrink: 0; }
.cc-btn {
font-family: var(--font-sans);
font-size: 0.88rem;
font-weight: 500;
padding: 0.5rem 1rem;
border: 1px solid var(--rule-strong);
background: var(--paper);
color: var(--ink);
cursor: pointer;
}
.cc-btn-primary {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.cc-btn-primary:hover { background: var(--accent-soft); }
.cc-btn-secondary:hover { color: var(--accent); border-color: var(--accent); }
</style>
<script is:inline>
(function () {
const KEY = 'ph-consent';
const el = document.getElementById('cookie-consent');
if (!el) return;
function setCookie(value) {
const exp = new Date(Date.now() + 365 * 24 * 3600 * 1000).toUTCString();
document.cookie = `${KEY}=${value}; expires=${exp}; path=/; SameSite=Lax`;
}
function activateAnalytics() {
document.querySelectorAll('script[type="text/plain"][data-cookieconsent="statistics"]').forEach((s) => {
const n = document.createElement('script');
if (s.src) n.src = s.src;
else n.textContent = s.textContent || '';
if (s.async) n.async = true;
document.head.appendChild(n);
});
}
function decide(value) {
try { localStorage.setItem(KEY, value); } catch {}
setCookie(value);
el.hidden = true;
if (value === 'accept') activateAnalytics();
}
const saved = (() => {
try { return localStorage.getItem(KEY); } catch { return null; }
})();
if (saved === 'accept') { activateAnalytics(); return; }
if (saved === 'deny') return;
el.hidden = false;
document.getElementById('cc-accept')?.addEventListener('click', () => decide('accept'));
document.getElementById('cc-deny')?.addEventListener('click', () => decide('deny'));
})();
</script>

View File

@@ -0,0 +1,82 @@
---
import { SITE_FOUNDED, FORUM_URL } from '../consts';
const year = new Date().getFullYear();
---
<footer class="site-footer">
<div class="container footer-inner">
<div>© {SITE_FOUNDED}{year} pushkinohistory.ru</div>
<div class="footer-nav">
<a href="/feed.xml">RSS</a>
<a href="/sitemap-index.xml">Карта сайта</a>
<a href={FORUM_URL} target="_blank" rel="noopener noreferrer">Форум ↗</a>
<a href="/privacy/">Политика</a>
</div>
</div>
<div class="container 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>
</div>
</footer>
<style>
.site-footer {
border-top: 1px solid var(--rule);
margin-top: 3rem;
padding: 1.25rem 0;
background: rgba(255, 252, 244, 0.4);
}
.footer-inner {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 0.82rem;
color: var(--muted);
}
.footer-nav { display: flex; gap: 1.25rem; }
.footer-nav a { color: var(--muted); text-decoration: none; }
.footer-nav a:hover { color: var(--accent); }
.dev-credit {
margin-top: 0.75rem;
color: var(--muted);
text-align: center;
}
.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.6;
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;
}
.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.74rem;
transition: max-width 240ms ease;
}
.hhivp-credit:hover .hhivp-credit-text,
.hhivp-credit:focus-visible .hhivp-credit-text { max-width: 200px; }
</style>

View File

@@ -1,17 +0,0 @@
import React from 'react';
export default function Footer() {
const year = new Date().getFullYear();
return (
<footer className="border-t border-rule mt-8 py-6 bg-paper">
<div className="max-w-6xl mx-auto px-4 sm:px-6 flex flex-col sm:flex-row sm:justify-between gap-2 text-xs text-muted">
<div>© 2010{year} pushkinohistory.ru</div>
<div className="flex gap-4">
<a href="/feed/">RSS</a>
<a href="/sitemap.xml">Карта сайта</a>
<a href="https://forum.pushkinohistory.ru/" target="_blank" rel="noopener noreferrer">Форум </a>
</div>
</div>
</footer>
);
}

124
src/components/Header.astro Normal file
View File

@@ -0,0 +1,124 @@
---
import { SITE_TITLE, SITE_TAGLINE, MAIN_NAV } from '../consts';
---
<header class="site-header">
<div class="hero">
<div class="hero-overlay"></div>
<div class="container hero-inner">
<a class="brand" href="/">
<h1 class="site-title">{SITE_TITLE}</h1>
<p class="site-tagline">{SITE_TAGLINE}</p>
</a>
<a class="rss-link" href="/feed.xml" title="RSS-фид">RSS</a>
</div>
</div>
<nav class="site-nav">
<div class="container nav-inner">
{MAIN_NAV.map((item) => (
<a
href={item.href}
target={item.external ? '_blank' : undefined}
rel={item.external ? 'noopener noreferrer' : undefined}
class="nav-link"
data-active={(Astro.url.pathname === item.href || (item.href !== '/' && Astro.url.pathname.startsWith(item.href)))}
>{item.label}</a>
))}
</div>
</nav>
</header>
<style>
.site-header {
border-bottom: 1px solid var(--rule);
background: var(--paper);
}
.hero {
position: relative;
isolation: isolate;
background-image: url('/uploads/IMG_2156.jpg');
background-size: cover;
background-position: center 60%;
background-color: var(--paper-deep);
overflow: hidden;
}
.hero-overlay {
position: absolute;
inset: 0;
background:
linear-gradient(180deg, rgba(244,236,219,0.55) 0%, rgba(244,236,219,0.7) 80%, rgba(244,236,219,0.85) 100%),
linear-gradient(0deg, rgba(74, 53, 25, 0.3), rgba(74, 53, 25, 0.15));
z-index: -1;
}
.hero-inner {
position: relative;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
padding-top: 3rem;
padding-bottom: 3rem;
min-height: 180px;
}
@media (max-width: 600px) {
.hero-inner { padding-top: 2rem; padding-bottom: 2rem; min-height: 140px; }
}
.brand { color: var(--ink); text-decoration: none; }
.brand:hover { text-decoration: none; }
.site-title {
font-family: var(--font-serif);
font-size: clamp(1.8rem, 4vw, 2.8rem);
font-weight: 700;
line-height: 1.1;
color: var(--ink);
letter-spacing: -0.01em;
text-shadow: 0 1px 0 rgba(244, 236, 219, 0.7);
}
.site-tagline {
font-family: var(--font-serif);
font-style: italic;
font-size: clamp(0.9rem, 1.5vw, 1.05rem);
color: var(--ink-soft);
margin: 0.3rem 0 0;
text-shadow: 0 1px 0 rgba(244, 236, 219, 0.6);
}
.rss-link {
font-family: var(--font-sans);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--ink-soft);
text-decoration: none;
padding: 0.3rem 0.5rem;
border: 1px solid var(--rule-strong);
background: rgba(244,236,219,0.7);
}
.rss-link:hover { color: var(--accent); border-color: var(--accent); }
.site-nav { border-top: 1px solid var(--rule); background: var(--paper); }
.nav-inner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 2.25rem;
padding-top: 0.7rem;
padding-bottom: 0.7rem;
}
@media (max-width: 600px) {
.nav-inner { gap: 0.5rem 1.25rem; }
}
.nav-link {
font-family: var(--font-sans);
font-size: 0.95rem;
font-weight: 500;
color: var(--ink);
text-decoration: none;
padding: 0.25rem 0;
border-bottom: 2px solid transparent;
}
.nav-link:hover { color: var(--accent); border-bottom-color: var(--accent-soft); }
.nav-link[data-active="true"] {
color: var(--accent);
border-bottom-color: var(--accent);
}
</style>

View File

@@ -1,62 +0,0 @@
import React from 'react';
const NAV = [
{ href: '/', label: 'Главная' },
{ href: '/history/', label: 'История' },
{ href: '/news/', label: 'Новости' },
{ href: '/foto/', label: 'Фото' },
{ href: 'https://forum.pushkinohistory.ru/', label: 'Форум', external: true },
];
export default function Header() {
return (
<header className="border-b border-rule bg-paper">
<div
className="relative overflow-hidden"
style={{
backgroundImage:
'linear-gradient(rgba(248,244,236,0.85), rgba(248,244,236,0.78)), url(/uploads/IMG_2156.jpg)',
backgroundSize: 'cover',
backgroundPosition: 'center 60%',
backgroundColor: '#f8f4ec',
}}
>
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8 sm:py-10">
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3">
<a href="/" className="block no-underline hover:no-underline">
<h1 className="font-serif text-3xl sm:text-4xl md:text-5xl font-bold tracking-tight text-ink leading-tight drop-shadow-[0_1px_0_rgba(248,244,236,0.6)]">
История города Пушкино
</h1>
<p className="font-serif italic text-sm sm:text-base text-muted mt-1">
от давних времён до наших дней
</p>
</a>
<a
href="/feed/"
className="text-xs uppercase tracking-wider text-muted hover:text-accent self-start sm:self-end"
title="RSS-фид"
>
RSS
</a>
</div>
</div>
</div>
<nav className="border-t border-rule">
<div className="max-w-6xl mx-auto px-4 sm:px-6 flex flex-wrap gap-x-6 gap-y-2 py-3 text-sm">
{NAV.map((n) => (
<a
key={n.href}
href={n.href}
target={n.external ? '_blank' : undefined}
rel={n.external ? 'noopener noreferrer' : undefined}
className="text-ink hover:text-accent no-underline font-medium"
>
{n.label}
{n.external ? ' ↗' : ''}
</a>
))}
</div>
</nav>
</header>
);
}

View File

@@ -0,0 +1,169 @@
---
import type { CollectionEntry } from 'astro:content';
import { firstImage, plainText, formatDateRu } from '../lib/extract';
import { CATEGORY_COLORS } from '../consts';
interface Props {
post: CollectionEntry<'posts'>;
featured?: boolean;
}
const { post, featured = false } = Astro.props;
const { title, slug, pubDate, categories, categorySlugs, description, featuredImage } = post.data;
const html = post.body ?? '';
const thumb = featuredImage || firstImage(html);
// Фильтруем "Главная" — это псевдо-категория «попадает на главную страницу», а не тематика.
const tagPairs = categories
.map((c, i) => ({ name: c, slug: categorySlugs[i] ?? c }))
.filter((p) => p.slug !== 'main');
const accent = tagPairs[0] ? CATEGORY_COLORS[tagPairs[0].slug] ?? 'var(--accent)' : 'var(--accent)';
const excerpt = description || plainText(html, featured ? 480 : 260);
---
<article class={`post-card ${featured ? 'is-featured' : ''} ${thumb ? 'has-thumb' : 'no-thumb'}`}>
{featured && (
<div class="featured-badge">Главная история</div>
)}
{thumb && (
<a class="thumb" href={`/${slug}/`} aria-hidden="true" tabindex="-1">
<img src={thumb} alt="" loading="lazy" />
</a>
)}
<div class="body">
<h2 class="title">
<a href={`/${slug}/`}>{title}</a>
</h2>
<div class="meta">
<time class="date" datetime={pubDate.toISOString()} style={`--cat: ${accent}`}>
<span class="date-mark">●</span>
<span>{formatDateRu(pubDate)}</span>
</time>
{tagPairs.length > 0 && (
<span class="cats">
{tagPairs.map((p, i) => (
<>
{i > 0 && <span class="sep">·</span>}
<a href={`/cat/${p.slug}/`}>{p.name}</a>
</>
))}
</span>
)}
</div>
<p class="excerpt">{excerpt}</p>
<a class="read-more" href={`/${slug}/`}>Читать далее →</a>
</div>
</article>
<style>
.post-card {
display: grid;
grid-template-columns: 1fr;
gap: 0;
padding: 1.25rem 0 1.5rem;
border-bottom: 1px solid var(--rule);
}
.post-card:last-child { border-bottom: 0; }
.post-card .thumb {
display: block;
margin-bottom: 1rem;
line-height: 0;
}
.post-card .thumb img {
max-width: 100%;
height: auto;
border: 1px solid var(--rule);
padding: 4px;
background: var(--paper);
}
@media (min-width: 640px) {
.post-card.has-thumb:not(.is-featured) {
grid-template-columns: 200px 1fr;
gap: 1.25rem;
}
.post-card.has-thumb:not(.is-featured) .thumb { margin-bottom: 0; }
.post-card.has-thumb:not(.is-featured) .thumb img { aspect-ratio: 4 / 3; object-fit: cover; width: 200px; }
}
.post-card.no-thumb:not(.is-featured) { grid-template-columns: 1fr; }
/* Featured-карточка с рамкой и плашкой */
.post-card.is-featured {
position: relative;
padding: 1.5rem;
background: rgba(255, 252, 244, 0.55);
border: 1px solid var(--rule-strong);
border-bottom: 2px solid var(--rule-strong);
margin-bottom: 1rem;
}
.is-featured .thumb img {
width: 100%;
max-height: 380px;
object-fit: cover;
aspect-ratio: 16 / 9;
}
.is-featured .title a { font-size: clamp(1.6rem, 2.6vw, 2.1rem); }
.featured-badge {
position: absolute;
top: -10px;
left: 1rem;
background: var(--accent);
color: var(--paper);
font-family: var(--font-sans);
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
padding: 0.2rem 0.6rem;
}
.title {
font-family: var(--font-serif);
font-size: 1.45rem;
font-weight: 700;
line-height: 1.2;
margin: 0;
}
.title a { color: var(--ink); text-decoration: none; }
.title a:hover { color: var(--accent); }
.meta {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.6rem;
margin: 0.35rem 0 0.8rem;
font-size: 0.82rem;
color: var(--muted);
}
.date {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-variant-numeric: tabular-nums;
}
.date-mark { color: var(--cat, var(--accent)); font-size: 0.7rem; }
.cats { color: var(--muted); }
.cats a {
color: var(--ink-soft);
text-decoration: none;
border-bottom: 1px dotted var(--rule-strong);
}
.cats a:hover { color: var(--accent); border-bottom-color: var(--accent); }
.sep { color: var(--rule-strong); margin: 0 0.2rem; }
.excerpt {
font-family: var(--font-serif);
font-size: 1.02rem;
line-height: 1.65;
color: var(--ink-soft);
margin: 0 0 0.6rem;
}
.read-more {
font-family: var(--font-sans);
font-size: 0.88rem;
color: var(--accent);
text-decoration: none;
}
.read-more:hover { color: var(--accent-soft); }
</style>

View File

@@ -1,46 +0,0 @@
import React from 'react';
function formatDate(s) {
const d = new Date(s.replace(' ', 'T'));
if (Number.isNaN(d.getTime())) return s;
return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' });
}
function stripHtml(html, max = 280) {
const text = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
return text.length > max ? text.slice(0, max).trimEnd() + '…' : text;
}
export default function PostCard({ post }) {
return (
<article className="border-b border-rule pb-6 mb-6 last:border-0">
<h2 className="font-serif text-2xl font-bold leading-tight">
<a href={`/${post.slug}/`} className="text-ink no-underline hover:text-accent">
{post.title}
</a>
</h2>
<div className="text-xs text-muted mt-1 mb-3">
<time dateTime={post.date}>{formatDate(post.date)}</time>
{post.categories?.length > 0 && (
<>
{' · '}
{post.categories.map((c, i) => (
<span key={c}>
{i > 0 && ', '}
<a href={`/cat/${post.categorySlugs[i]}/`} className="text-muted hover:text-accent">
{c}
</a>
</span>
))}
</>
)}
</div>
<p className="font-serif text-[1.02rem] leading-relaxed text-ink/90">
{post.excerpt || stripHtml(post.html)}
</p>
<div className="mt-3 text-sm">
<a href={`/${post.slug}/`}>Читать далее </a>
</div>
</article>
);
}

View File

@@ -0,0 +1,92 @@
---
import { getCollection } from 'astro:content';
import transport from '../data/transport.json';
import partners from '../data/partners.json';
import ads from '../data/ads.json';
import { plural } from '../consts';
const allPosts = await getCollection('posts');
// Категории с подсчётом — но без "main" (это псевдо-флаг «попадает на главную»).
const catCount = new Map<string, { slug: string; name: string; count: number }>();
for (const p of allPosts) {
p.data.categorySlugs.forEach((slug, i) => {
if (slug === 'main') return;
const name = p.data.categories[i] ?? slug;
if (!catCount.has(slug)) catCount.set(slug, { slug, name, count: 0 });
catCount.get(slug)!.count++;
});
}
const categories = [...catCount.values()].sort((a, b) => b.count - a.count);
---
<aside class="sidebar">
<section class="box">
<h3>Транспорт</h3>
<ul>
{transport.trains.map((t: { label: string; url: string }) => (
<li><a href={t.url} target="_blank" rel="noopener noreferrer">{t.label}</a></li>
))}
{transport.buses.map((b: { label: string; url: string }) => (
<li><a href={b.url} target="_blank" rel="noopener noreferrer">{b.label}</a></li>
))}
</ul>
</section>
{categories.length > 0 && (
<section class="box">
<h3>Рубрики</h3>
<ul>
{categories.map((c) => (
<li>
<a href={`/cat/${c.slug}/`}>
<span>{c.name}</span>
<span class="cat-count">{c.count}&nbsp;{plural(c.count, ['запись', 'записи', 'записей'])}</span>
</a>
</li>
))}
</ul>
</section>
)}
{partners.length > 0 && (
<section class="box">
<h3>Наши партнёры</h3>
<ul>
{partners.map((p: { name: string; url: string; note: string }) => (
<li>
{p.url ? <a href={p.url} target="_blank" rel="noopener noreferrer">{p.name}</a> : <span>{p.name}</span>}
{p.note && <span class="note"> — {p.note}</span>}
</li>
))}
</ul>
</section>
)}
{ads.length > 0 && (
<section class="box">
<h3>Объявления</h3>
<ul>
{ads.map((a: { text: string; url?: string }) => (
<li>
{a.url ? <a href={a.url} target="_blank" rel="noopener noreferrer">{a.text}</a> : <span>{a.text}</span>}
</li>
))}
</ul>
</section>
)}
</aside>
<style>
.sidebar a {
display: flex;
justify-content: space-between;
gap: 0.5rem;
align-items: baseline;
color: var(--ink-soft);
text-decoration: none;
}
.sidebar a:hover { color: var(--accent); }
.cat-count { color: var(--muted); font-size: 0.78rem; white-space: nowrap; }
.note { color: var(--muted); font-size: 0.85rem; }
</style>

View File

@@ -1,67 +0,0 @@
import React from 'react';
import { transport, partners, ads } from '../content';
function Box({ title, children }) {
return (
<section className="border border-rule bg-white/40 p-4">
<h3 className="font-serif text-base font-bold mb-2 pb-2 border-b border-rule">
{title}
</h3>
{children}
</section>
);
}
export default function Sidebar() {
return (
<aside className="space-y-6 text-sm">
<Box title="Транспорт">
<ul className="space-y-2">
{transport.trains.map((t) => (
<li key={t.url}>
<a href={t.url} target="_blank" rel="noopener noreferrer">{t.label}</a>
</li>
))}
{transport.buses.map((b) => (
<li key={b.url}>
<a href={b.url} target="_blank" rel="noopener noreferrer">{b.label}</a>
</li>
))}
</ul>
</Box>
{partners.length > 0 && (
<Box title="Наши партнёры">
<ul className="space-y-2">
{partners.map((p) => (
<li key={p.name}>
{p.url ? (
<a href={p.url} target="_blank" rel="noopener noreferrer">{p.name}</a>
) : (
<span>{p.name}</span>
)}
{p.note && <span className="text-muted"> {p.note}</span>}
</li>
))}
</ul>
</Box>
)}
{ads.length > 0 && (
<Box title="Объявления">
<ul className="space-y-2">
{ads.map((a, i) => (
<li key={i}>
{a.url ? (
<a href={a.url} target="_blank" rel="noopener noreferrer">{a.text}</a>
) : (
<span>{a.text}</span>
)}
</li>
))}
</ul>
</Box>
)}
</aside>
);
}

52
src/consts.ts Normal file
View File

@@ -0,0 +1,52 @@
/** Site identity. */
export const SITE_TITLE = 'История города Пушкино';
export const SITE_TAGLINE = 'от давних времён до наших дней';
export const SITE_DESCRIPTION =
'Прошлое, настоящее, будущее города Пушкино: история, фото, новости, форум.';
export const SITE_URL = 'https://pushkinohistory.ru';
export const SITE_LANG = 'ru-RU';
export const SITE_FOUNDED = 2010;
/** Forum subdomain (отдельный IPB). */
export const FORUM_URL = 'https://forum.pushkinohistory.ru/';
/** Analytics IDs (ставить настоящие при подключении). */
export const ANALYTICS = {
yandexMetrika: '', // например '13938862'
googleGtag: '', // например 'GT-XXXXXXX'
};
/**
* RSS-фид отдаёт только посты с pubDate >= этой даты.
* Сейчас выставлено на 2010 (все 7 постов уйдут в фид; для IPB-импорта одной партии).
* Если запустят регулярный IPB Importer и не хочется лить старые архивы при следующем
* перезапуске — поменять на свежую дату и перезапустить build.
*/
export const RSS_CUTOFF = new Date('2010-01-01T00:00:00+03:00');
/** Сколько постов в RSS-фиде максимум (после фильтра cutoff). */
export const RSS_LIMIT = 50;
/** Русская плюрализация: [1, 2-4, 5+]. */
export function plural(n: number, forms: [string, string, string]): string {
const a = Math.abs(n) % 100;
const b = a % 10;
if (a > 10 && a < 20) return forms[2];
if (b > 1 && b < 5) return forms[1];
if (b === 1) return forms[0];
return forms[2];
}
export const MAIN_NAV = [
{ label: 'Главная', href: '/' },
{ label: 'История', href: '/history/' },
{ label: 'Новости', href: '/news/' },
{ label: 'Фото', href: '/foto/' },
{ label: 'Форум ↗', href: FORUM_URL, external: true },
];
export const CATEGORY_COLORS: Record<string, string> = {
main: 'var(--accent)',
today: 'var(--c-today)',
tech: 'var(--c-tech)',
};

37
src/content.config.ts Normal file
View File

@@ -0,0 +1,37 @@
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const posts = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/posts' }),
schema: z.object({
title: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
slug: z.string(),
legacyId: z.union([z.string(), z.number()]).optional(),
oldSlug: z.string().optional(),
author: z.string().default('История города Пушкино'),
categories: z.array(z.string()).default([]),
categorySlugs: z.array(z.string()).default([]),
tags: z.array(z.string()).default([]),
description: z.string().default(''),
featuredImage: z.string().optional(),
featured: z.boolean().default(false),
hideFromList: z.boolean().default(false),
}),
});
const pages = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/pages' }),
schema: z.object({
title: z.string(),
slug: z.string(),
legacyId: z.union([z.string(), z.number()]).optional(),
oldSlug: z.string().optional(),
pubDate: z.coerce.date().optional(),
updatedDate: z.coerce.date().optional(),
description: z.string().default(''),
}),
});
export const collections = { posts, pages };

View File

@@ -1,38 +0,0 @@
import postsArr from './posts.json';
import pagesArr from './pages.json';
import partnersArr from './partners.json';
import adsArr from './ads.json';
import transportArr from './transport.json';
import feedsArr from './feeds.json';
const byField = (arr, field) => Object.fromEntries(arr.map((x) => [x[field], x]));
export const postsList = postsArr;
export const pagesList = pagesArr;
export const posts = byField(postsArr, 'slug');
export const pages = byField(pagesArr, 'slug');
export const oldSlugRedirects = (() => {
const map = {};
for (const p of postsArr) {
if (p.oldSlug && p.oldSlug !== p.slug) map[`/${p.oldSlug}/`] = `/${p.slug}/`;
}
for (const p of pagesArr) {
if (p.oldSlug && p.oldSlug !== p.slug) map[`/${p.oldSlug}/`] = `/${p.slug}/`;
}
return map;
})();
export const partners = partnersArr;
export const ads = adsArr;
export const transport = transportArr;
export const externalFeeds = feedsArr;
export const categories = (() => {
const set = new Map();
for (const p of postsArr) {
p.categorySlugs?.forEach((s, i) => {
if (!set.has(s)) set.set(s, { slug: s, name: p.categories[i], count: 0 });
set.get(s).count += 1;
});
}
return [...set.values()];
})();

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,22 @@
---
title: "Главная"
slug: dobro-pozhalovat
legacyId: 23
pubDate: 2010-02-22T18:52:14+03:00
description: "Портал об истории, настоящем и будущем города Пушкино: краеведческие материалы, фотоархив и форум для жителей и всех, кто интересуется историей Пушкинского района."
oldSlug: "%d0%b4%d0%be%d0%b1%d1%80%d0%be-%d0%bf%d0%be%d0%b6%d0%b0%d0%bb%d0%be%d0%b2%d0%b0%d1%82%d1%8c"
---
<p style="text-align: center;"><strong><span style="color: #000000; size: 18;"><a href="/uploads/IMG_2156.jpg"><img class="aligncenter size-medium wp-image-42" title="Пушкино с высоты" src="/uploads/IMG_2156.jpg" alt="" width="300" height="225" /></a></span></strong></p>
<p style="text-align: center;"><strong><span style="color: #000000; size: 18;">Приветствуем Вас на страницах портала, посвященного</span></strong></p>
<p style="text-align: center;"><strong>истории города Пушкино и всего что с ним связано!</strong></p>
<p>В настоящее время портал находится в стадии разработки, но уже запущен тестовый <span style="text-decoration: underline;"><a href="http://forum.pushkinohistory.ru/">форум</a></span>, на котором уже можно общаться как с нами, создателями сайта, так и с другими пользователями. Надеемся, что вам здесь понравится!</p>
<p style="text-align: right;">С уважением, администрация портала!</p>
<p style="text-align: center;"><a href="/uploads/IMG_2754.jpg"><img class="aligncenter size-medium wp-image-50" title="Мемориал" src="/uploads/IMG_2754.jpg" alt="" width="300" height="217" /></a></p>

View File

@@ -0,0 +1,15 @@
---
title: "Форум"
slug: forum
legacyId: 94
pubDate: 2010-04-02T11:00:52+03:00
description: "Форум сайта pushkinohistory.ru — площадка для общения жителей и гостей города Пушкино, обсуждения истории, новостей и событий."
---
<p><meta http-equiv="Refresh" content="0;url=http://forum.pushkinohistory.ru"><br />
<center>перенаправление на форум....<br />
не хотите ждать - нажмите <b><a href="http://forum.pushkinohistory.ru" target="_self">сюда</a></b></center></p>

32
src/content/pages/foto.md Normal file
View File

@@ -0,0 +1,32 @@
---
title: "Фото"
slug: foto
legacyId: 73
pubDate: 2010-03-27T23:00:41+03:00
description: "Фотоархив города Пушкино: исторические и современные фотографии, виды города в разные эпохи."
oldSlug: "%d1%84%d0%be%d1%82%d0%be"
---
<table style="height: 100%;" border="0" cellspacing="0" cellpadding="0" width="100%" align="center">
<tbody>
<tr width="100%">
<td width="50%" align="center" valign="middle">
[caption id="attachment_87" align="aligncenter" width="150" caption="Прошлое"]<a href="http://www.pushkinohistory.ru/archives/75"><img class="size-full wp-image-87" title="pushkino" src="/uploads/pushkino.jpg" alt="Прошлое" width="150" height="200" /></a>[/caption]</td>
<td width="50%" align="center" valign="middle">
[caption id="attachment_85" align="aligncenter" width="156" caption="Настоящее"]<a href="http://www.pushkinohistory.ru/archives/90"><img class="size-full wp-image-85 " title="pushkin1" src="/uploads/pushkin1.gif" alt="Настоящее" width="156" height="200" /></a>[/caption]</td>
</tr>
</tbody>
</table>

View File

@@ -0,0 +1,109 @@
---
title: "История"
slug: history
legacyId: 20
pubDate: 2010-02-22T18:46:33+03:00
description: "История города Пушкино с XIV века: от боярина Григория Пушки до современного города. Краеведческие материалы, архивные сведения, история поселений Пушкинского района."
---
<div id="postContainer32317411">
<div id="post32317411">
<div>
<div id="comment32317411"><a href="/uploads/post-78-1227537465.jpg"><img class="size-medium wp-image-39 alignleft" title="Станция Пушкино" src="/uploads/post-78-1227537465.jpg" alt="" width="300" height="191" /></a>По наиболее распространённой версии, название Пушкино произошло во второй половине XIV века, когда местностью по реке Уче владел боярин Григорий Александрович Морхинин, по прозвищу Пушка — предок поэта Александра Сергеевича Пушкина.Историк С. Б. Веселовский указывает: «По актам известно, что в конце XV века оно принадлежало как „старинное“ митрополитам всея Руси, а после учреждения патриаршества стало домовой вотчиной патриархов. Как и когда оно досталось митрополичьему дому, неизвестно. Возможно, что оно было приобретено в третьей четверти XIV в. митрополитом Алексеем непосредственно у Григория Пушки, но не исключена возможность, что оно было отчуждено кем-либо из многочисленных потомков Григория Пушки в XV в.».
Первое документальное упоминание о селе Пушкино относится к 1499 году («писцовая книга князя В. И. Голенина на митрополичье село Пушкино Московского уезда»). Село находилось на древнейшей в Северо-Восточной Руси торговой дороге по пути в Переславль, Ярославль, Вологду, что способствовало росту его населения и высокому достатку жителей.
Летний театр в городском парке, начало 20 века
Во 2-й половине XVIII века начинает развиваться ткацкий промысел: производство шерстяного сукна, каразеи, кушаков, шёлковых платков. В 1-й половине XIX века в с. Пушкино в это время открываются медный завод, шерстоткацкая фабрика, оснащённая одной из первых в Московском уезде механическими станками. К концу 19 столетия село превратилось в фабричный центр.
В 1867 года началось дачное строительство. В 1868 году было открыто земское училище для детей от 8 до 14 лет. В 1890 году на средства Арманда открылась библиотека.
В 1880 году недалеко от станции был разбит парк, и он стал излюбленным местом отдыха пушкинских дачников. В 1896 году на средства страхового общества «Якорь» был выстроен в парке летний театр, сгоревший летом 1993 года.
По данным справочника 1890 года в селе Пушкино, которое тогда входило в Мытищинскую волость Московского уезда Московской губернии, проживало 1164 человека, а в 1899 году проживало уже 1560 человек. Согласно границам населённых пунктов того времени село Пушкино находилось в 27 километрах от Москвы и в полутора километрах от станции «Пушкино» Московско-Ярославско-Архангельской железной дороги. От вокзала до станции «Пушкино» 30 километров. В то время в Пушкине располагалась квартира урядника, земское училище, училище при фабрике Арманд и богадельня.
В справочнике 1912 года на месте современного города указано два различных населённых пункта:
* село Пушкино в 233 двора, расположенное в 2,7 км от станции «Пушкино». В селе находились квартиры 2-х урядников, земское училище, фабрика Арманд, больница при фабрике и аптека, церковно-приходская школа, потребительская лавка, вольная пожарная дружина, казённая винная лавка, трактир 2-го разряда, трактир 3-го разряда, ренсковый погреб, две пивных трактирного промысла.
* отдельно указано дачное место «Пушкино», расположенное в непосредственной близости от ж/д станции «Пушкино», без указания количества домов. В дачном месте находилась квартира пристава 4 стана и конно-полицейской стражи и церковно-приходская школа. Кроме того, в справочнике 1909 года в дачном посёлке числятся приют для выздоравливающих детей Беренштама, приют для излечения душевнобольных привилегированного сословия и лечебница Голубевской.<!--more-->В 1914 году в селе Пушкине располагалась почтово-телеграфная контора (заведующий Н. И. Лаврентьев). Почтовый адрес на станции «Пушкино» в 1914 году имело несколько предприятий: кирпичный завод Дмитриева в д. Чапчиково, шерстопрядильная фабрика братьев Кеворковых при с. Курове, шерстопрядильная, ткацкая и отделочная фабрика товарищества Лыжина при с. Вантеевке, красильно-аппретурная фабрика товарищества Ветерме, аппретурная фабрика Копылова и Анкудинова при д. Немичнове. Непосредственно в селе Пушкино находились два предприятия:
* Суконная фабрика Товарищества Суконной Мануфактуры, заведующий Н. С. Масленников (основана в 1883 году, в 1914 году 257 работающих, из которых 257 — мужчины);
* Механическая ткацкая и красильно-отделочная фабрика Е. Арманд, заведующий А. Л. Коль (основана в 1860 году, в 1914 году 1059 работающих, из которых 600 — мужчины).
В 1914 году в селе Пушкине была аптека (провизор — В. Ф. Блументаль), Андреевская больница при фабрике Арманд (врачи А. Л. Коль и В. Ф. Буров), Богадельня для престарелых фабричных рабочих обоего пола, учреждённая потомственным почётным гражданином Евгением Ивановичем Арманд (заведующий Е. И. Арманд, призреваемых 18 человек). При станции «Пушкино» действовал приют императорского человеколюбивого общества для выздоравливающих детей имени Альберта и Анны Беренштам (заведующая Е. А. Алексеева, призреваемых 30 детей). В 1914 году в Пушкине было несколько начальных учебных заведений разных ведомств: Пушкинское I-е и Пушкинское II-е начальные земские начальные училища, начальная Пушкинская церковно-приходская школа.7 августа 1925 года Пушкино получает статус города. В его состав вошёл дачный посёлок у станции и часть села Пушкино. 12 июня 1929 года город Пушкино стал районным центром. В состав района вошли два рабочих посёлка — Ивантеевка и Красноармейск, дачный посёлок Мамонтовка; Софринская, Путиловская, Пушкинская волости; несколько селений Щелковской и Хотьковской волостей. В том же году из Москвы в Пушкино прошла первая электричка. Через год электропоезда шли уже до станции Правда.
Во время ВОВ свыше 36 тысяч жителей района были направлены в вооружённые силы. На территории Пушкинского района среди прочих была сформирована легендарная отдельная мотострелковая бригада особого назначения (ОМСБОН).Осенью 1941 года линия фронта проходила в 25 км от Пушкино и в 15 км от Тишково. В строительстве оборонительных сооружений на ближних подступах к столице в октябре-ноябре участвовало более 15 тысяч пушкинцев.
В 1953 году, город Пушкино был отнесён к категории городов областного подчинения. Началось бурное строительство, был застроен Московский проспект, построен микрорайон «Серебрянка». В 1970-е годы — микрорайон «Дзержинец».
Поэт Владимир Маяковский жил в Пушкино в летние сезоны 1920—1928 гг. Стало хрестоматийным стихотворение «Необычайное приключение», где вместо эпиграфа — точный адрес проживания поэта: «Пушкино. Акулова гора, дача Румянцева, 27 вёрст по Ярославской железной дороге». В 1969 году на Акуловой горе открылась библиотека — музей поэта.В 2009 году от дачи не осталось и следа, лишь поляна среди старых лип. И только памятник В.В. Маяковскому на вершине Акуловой горы напоминает о прошлых годах. В городе Пушкино находился приход убитого православного протоиерея Александра Меня (район Новая Деревня).
В Пушкинском районе действуют пять высших учебных заведений.
* Российский государственный университет туризма и сервиса
* Академия экономической безопасности МВД России
* Академия государственной противопожарной службы Министерства по делам ГО и ЧС и ликвидации последствий стихийных бедствий
* Институт технологии туризма
* Современная гуманитарная академия (Пушкинский филиал)
В районе работают также 46 общеобразовательных школ и шесть средних специальных образовательных учреждения.
* 2-е Московское областное музыкальное училище имени С. С. Прокофьева
* Пушкинский медицинский колледж
* Московский колледж сервиса
* Правдинский лесхоз-техникум
* Школа усовершенствования руководящего состава ведомственной охраны МПС РФ (набор учащихся не ведётся, преподавательский состав расформирован)
* Колледж экономики, политики и права
* Гимназия № 10 г. Пушкино
Пушкино — один из центров лесной науки России. На территории города находится Всероссийский научно-исследовательский институт лесоводства и механизации лесного хозяйства (ВНИИЛМ). Здание ВНИИЛМ расположено в центре дендрологического парка площадью 13 га.
Через Пушкино проходят Ярославское шоссе и линия железной дороги на Ярославль и Архангельск, являющаяся также началом Транссибирской магистрали. Моторвагонное депо Пушкино является главным на линии Москва — Сергиев Посад, железнодорожная станция — конечным пунктом многих электричек.
</div>
</div>
</div>
</div>

View File

@@ -21,7 +21,7 @@
"title": "Сегодня ночью россияне увидят первое суперлуние года - Волчью луну",
"date": "2026-01-04 04:19:20",
"excerpt": "",
"html": "<!-- wp:paragraph -->\n<p></p>\n<!-- /wp:paragraph -->\n\n<!-- wp:image {\"id\":227,\"sizeSlug\":\"large\",\"linkDestination\":\"media\",\"className\":\"is-style-default\"} -->\n<figure class=\"wp-block-image size-large is-style-default\"><a href=\"/uploads/image.png\"><img src=\"/uploads/image-1024x581.png\" alt=\"\" class=\"wp-image-227\"/></a></figure>\n<!-- /wp:image -->\n\n<!-- wp:paragraph -->\n<p>«В ночь с 3 на 4 января россияне смогут увидеть яркое астрономическое событие — суперполнолуние. Полная Луна приблизится к Земле на максимально близкое расстояние, ее диск будет выглядеть на 15% ярче и почти на 8% больше среднего полнолуния.</p>\n<!-- /wp:paragraph -->\n\n<!-- wp:paragraph -->\n<p>Это явление январской суперлуны имеет фольклорное название — Волчья Луна, потому что в холодное время года волки чаще воют по ночам»</p>\n<!-- /wp:paragraph -->",
"html": "<!-- wp:paragraph -->\n<p></p>\n<!-- /wp:paragraph -->\n\n<!-- wp:image {\"id\":227,\"sizeSlug\":\"large\",\"linkDestination\":\"media\",\"className\":\"is-style-default\"} -->\n<figure class=\"wp-block-image size-large is-style-default\"><a href=\"/uploads/image.png\"><img src=\"/uploads/image.png\" alt=\"\" class=\"wp-image-227\"/></a></figure>\n<!-- /wp:image -->\n\n<!-- wp:paragraph -->\n<p>«В ночь с 3 на 4 января россияне смогут увидеть яркое астрономическое событие — суперполнолуние. Полная Луна приблизится к Земле на максимально близкое расстояние, ее диск будет выглядеть на 15% ярче и почти на 8% больше среднего полнолуния.</p>\n<!-- /wp:paragraph -->\n\n<!-- wp:paragraph -->\n<p>Это явление январской суперлуны имеет фольклорное название — Волчья Луна, потому что в холодное время года волки чаще воют по ночам»</p>\n<!-- /wp:paragraph -->",
"categories": [
"Главная"
],

View File

@@ -0,0 +1,27 @@
---
title: "C наступающим Новым 2014 Годом!"
slug: c-nastupayushhim-novym-2014-godom
legacyId: 145
pubDate: 2013-12-31T19:29:10+03:00
description: "Поздравление с Новым 2014 годом от администрации краеведческого портала pushkinohistory.ru."
categories:
- "Главная"
- "Настоящее"
- "Техническое"
categorySlugs:
- "main"
- "today"
- "tech"
author: "История города Пушкино"
hideFromList: true
---
От лица администрации и от себя лично поздравляю всех с Новым годом! Желаю всем счастья, здоровья, сбытия всех Ваших мечт, финансового и душевного спокойствия, искорки в глазах и успехов в выполнении всех Ваших начинаний.Постарайтесь забыть все то плохое, что у Вас может быть случилось, помните, что свою жизнь Вы делаете и сами, а значит нужно стремиться к самому лучшему! Еще раз всех с праздником, до встречи уже в Новом году!)
&nbsp;
admin &amp; pushkinohistory.ru

View File

@@ -0,0 +1,17 @@
---
title: "Первые 20-градусные морозы"
slug: pervye-20-gradusnye-morozy
legacyId: 235
pubDate: 2026-01-05T02:26:32+03:00
description: "Синоптики прогнозируют первые 20-градусные морозы в Москве и Московской области — местами до -25°C к концу следующей недели."
categories:
- "Главная"
categorySlugs:
- "main"
author: "История города Пушкино"
oldSlug: "%d0%bf%d0%b5%d1%80%d0%b2%d1%8b%d0%b5-20-%d0%b3%d1%80%d0%b0%d0%b4%d1%83%d1%81%d0%bd%d1%8b%d0%b5-%d0%bc%d0%be%d1%80%d0%be%d0%b7%d1%8b"
---
<!-- wp:paragraph -->
<p>Первые 20-градусные морозы ударят в Москве и МО к концу следующей недели, местами температура опустится до минус 25 градусов, сообщают синоптики.</p>
<!-- /wp:paragraph -->

View File

@@ -0,0 +1,29 @@
---
title: "Сегодня ночью россияне увидят первое суперлуние года - Волчью луну"
slug: segodnya-nochyu-rossiyane-uvidyat-pervoe
legacyId: 226
pubDate: 2026-01-04T04:19:20+03:00
description: "Волчья Луна — первое суперполнолуние 2026 года: в ночь с 3 на 4 января Луна приблизится к Земле на максимальное расстояние, диск будет на 15% ярче обычного."
categories:
- "Главная"
categorySlugs:
- "main"
author: "История города Пушкино"
oldSlug: "%d1%81%d0%b5%d0%b3%d0%be%d0%b4%d0%bd%d1%8f-%d0%bd%d0%be%d1%87%d1%8c%d1%8e-%d1%80%d0%be%d1%81%d1%81%d0%b8%d1%8f%d0%bd%d0%b5-%d1%83%d0%b2%d0%b8%d0%b4%d1%8f%d1%82-%d0%bf%d0%b5%d1%80%d0%b2%d0%be%d0%b5"
---
<!-- wp:paragraph -->
<p></p>
<!-- /wp:paragraph -->
<!-- wp:image {"id":227,"sizeSlug":"large","linkDestination":"media","className":"is-style-default"} -->
<figure class="wp-block-image size-large is-style-default"><a href="/uploads/image.png"><img src="/uploads/image.png" alt="" class="wp-image-227"/></a></figure>
<!-- /wp:image -->
<!-- wp:paragraph -->
<p>«В ночь с 3 на 4 января россияне смогут увидеть яркое астрономическое событие — суперполнолуние. Полная Луна приблизится к Земле на максимально близкое расстояние, ее диск будет выглядеть на 15% ярче и почти на 8% больше среднего полнолуния.</p>
<!-- /wp:paragraph -->
<!-- wp:paragraph -->
<p>Это явление январской суперлуны имеет фольклорное название — Волчья Луна, потому что в холодное время года волки чаще воют по ночам»</p>
<!-- /wp:paragraph -->

View File

@@ -0,0 +1,28 @@
---
title: "Старое, Старое Село"
slug: staroe-staroe-selo
legacyId: 137
pubDate: 2012-05-16T18:28:48+03:00
description: "История древнего села в Пушкинском районе с XIV века: боярские владения, усадьбы Несвицких, предание о море и возрождении поселения. Автор — Василий Коршун."
categories:
- "Главная"
categorySlugs:
- "main"
author: "История города Пушкино"
featured: true
featuredImage: "/uploads/IMG_2754.jpg"
---
<div align="justify"> Старое село и вправду является одним из древнейших поселений на территории Пушкинского района. По данным археологов оно возникло в конце XIII первой четверти XIV веков. Его возникновение связано, скорее всего, с началом бурного развития Московского княжества после татаро-монгольского нашествия. Расцвет села был, видимо, во второй половине XV в. Письменные источники доносят до нас весть о существовании в 1495-99 гг. <span style="color: #009900;"><em><span style="color: #006600;">Веденской*</span> </em></span>волости, принадлежавшей Великому Князю Иоанну Васильевичу. Несложный анализ топонимики позволяет предположить, что центром волости было село с церковью Введения во храм Пресвятой Богородицы. Нет особых оснований предполагать, что речь идет о каком-то другом поселении, так как Веденская в верховьях Прорванихи упоминается в 1503 г., правда, уже как деревня. Что ж, пожары церквей в те времена были делом обыденным… Согласно местной легенде, в незапамятные времена в селе произошел мор. Выжившие жители покинули насиженные места и, спустившись вниз по реке, основали селение с тем же названием. Видимо, речь в предании идет об эпидемии 1571 г., после которой село и вправду запустело. К этому же периоду относится основание села Введенского, которое дожило до нашего времени под тем же названием. Продолжая изучать вышеупомянутую легенду, мы узнаем, что жителей постоянно тянуло в родные места, и, спустя некоторое время, они вернулись на пепелище, основав поселение с названием Старое Веденское. И действительно, в середине XVII в. писцовые книги упоминают село Старое Веденское владения дьяка Семена Филатовича Домашнева. Как мы видим, местное придание целиком подтверждается документально. Это достаточно редкое явление, так как полет народной фантазии в вопросах трактования топонимики просто потрясающий! Что только не придумывают! Возьмем хотя бы то же Пушкино. И по Уче оно стояло (Поучкино), и пушки здесь отливали, и к опушке леса оно тяготело… Правда, мне видится, наиболее правдоподобным вариант академика С.Б. Веселовского, предполагавшего, что первым владельцем селения был в начале XIV века боярин Григорий Алек­сандрович Морхинин по прозвищу Пушка, и оно получило название по его имени, но документаль­ных доказательств этого нет. Действительно, почти 80% процентов древних названий связано с именами и фамилиями владельцев (<span style="color: #006600;"><em>Борково*, Елдегино*</em>,</span> Курово, Муромцево, Рахманово,<span style="color: #006600;"><em>Сафарино*</em></span>, Тишково, Царево и др.) На втором месте идут названия по церквям (Богородское, Введенское, Спасское и т.д.), на третьем особенности местности (Березняки, Могильцы, Нагорное, Подлипки, Подвязное и т.д.). Прочие варианты крайне редки, особенно для Московской области.</div>
<div align="justify">     Однако вернемся в наше Старое Село. С конца XVII в. сельцо Старое Веденское принадлежало князьям Несвицким, род которых идет от великого князя литовского Гедимина: в 1704 г. стольнику Ивану Михайловичу Несвицкому, в 1709 г. его сыну Михаилу Ивановичу, а в 1720 г. вдове Михаила Иванович Марии Ивановне. Затем сельцо унаследовал их сын лейб гвардии Семеновского полка поручик князь Василий Михайлович Несвицкий, а в 1768 г. его брат Николай Михайлович. Тогда сельцо представляло собой типичную сельскую помещичью средней руки усадьбу той поры. Там находился деревянный господский дом, хозяйственные постройки и парк с диагональными аллеями при двух прудах. Интересно, что Василий Михайлович имел дом в Москве, который впоследствии принадлежал П.А. Офросимову, о котором уже в этом году сообщалось как о владельце сельца Паршино. Однако постепенно усадьба в Старом Веденском приходит в упадок и в 1852 г. селение упоминается как деревня Старое Село, владение коллежской советницы Марии Осиповны Зверевой. Под этим названием оно известно и в наши дни.
</div>
<div align="justify">   <em>  <span style="color: #006600;">* Названия приведены по написанию их в документах XV-XVIII вв. С этим связана одна буква «В» в названии Введенское. В названиях владельческих сел явно читаются фамилии владельцев Борков, Елдегин, Сафарин.</span></em></div>
Василий Коршун
Опубликовано: Пушкинский Вестник, № 2(259)  2005

View File

@@ -0,0 +1,19 @@
---
title: "Внимание! Технические работы!"
slug: vnimanie-texnicheskie-raboty-2
legacyId: 158
pubDate: 2015-05-29T01:33:35+03:00
description: "2 июня 2015 года с 3:00 до 4:00 возможны кратковременные перебои в работе сайта в связи с плановыми техническими работами на серверах."
categories:
- "Главная"
- "Техническое"
categorySlugs:
- "main"
- "tech"
author: "История города Пушкино"
hideFromList: true
---
2  июня  2015г. с 3 до 4 утра, в связи с проведением технических работ на площадке оператора связи, обслуживающего нашу инфраструктуру, возможны перебои с предоставлением услуг длительностью до 30 минут.
Приносим  Вам  свои извинения за доставленные неудобства и надеемся на Ваше понимание!

View File

@@ -0,0 +1,19 @@
---
title: "Внимание! Технические работы!"
slug: vnimanie-texnicheskie-raboty
legacyId: 142
pubDate: 2013-08-24T00:56:06+03:00
description: "24 августа 2013 года с 2:00 до 4:00 возможны кратковременные перебои в работе сайта в связи с техническими работами на серверах."
categories:
- "Главная"
categorySlugs:
- "main"
author: "История города Пушкино"
hideFromList: true
---
В связи с проведением технических работ на серверах с 02:00 до 04:00 24 августа 2013 г. возможны перерывы в работе до 30 минут.
Приносим свои извинения за доставленные неудобства.

View File

@@ -0,0 +1,56 @@
---
title: "Воронино"
slug: voronino
legacyId: 139
pubDate: 2012-05-16T18:30:34+03:00
description: "История исчезнувшего села Воронино в Пушкинском районе с XV века до пожара 1940-х годов: усадьбы Шереметевых, Долгоруковых, Верстовского и Толстых."
categories:
- "Главная"
categorySlugs:
- "main"
author: "История города Пушкино"
featured: true
featuredImage: "/uploads/IMG_2156.jpg"
---
<div align="justify"><em>Населенного пункта с таким названием сейчас нет на карте Пушкинского района. Село сгорело в 1940-х гг. и более не возрождалось. Однако не хотелось бы, что бы его многовековая история была полностью забыта. Некоторые страницы ее мы попробуем сейчас приоткрыть.</em>
Село известно с первой четверти XV в. Это подтверждает найденная там створка <em>энколпиона*</em> с изображением сюжета Крещения. Иконография ее близка к изделиям новгородской культовой металлопластики XIV в. Возможное время отливки XIV-XVI вв.
<a name="cutid1"></a><a href="http://pics.livejournal.com/pushkino_2009/pic/0009ywcp"><img src="http://pics.livejournal.com/pushkino_2009/pic/0009ywcp/s640x480" alt="" width="202" height="291" border="0" /></a>
</div>
<div align="justify">Воронино с 1582 г. находится в дворцовом ведомстве. В 1619 г. по государевой грамоте дача села Воронино переходит в вотчину князьям Василию и Борису Петровичам Шереметевым и в поместье «князю Луке да сыну его Миките Щербатовым». В 1623 г. там числились два двора помещиков и деревянная церковь Покрова Пресвятой Богородицы. Впоследствии владение распадается на две части: левобережную Большое Воронино и правобережную Малое Воронино. Судьба церкви неизвестна, но в 1646 г. сельцо Большое Воронино вотчина боярина Василия Петровича Шереметева. Василий Петрович был талантливым военноначальником и помогал Богдану Хмельницкому в его борьбе с поляками в 1654-1655 гг. В 1677 г. Большое Воронино числится собственностью его сына Петра. Тогда же упоминается деревянная часовня при кладбище. В 1704 г. сельцо унаследовал Киевский губернатор, генерал-лейтенант Шереметев Владимир Петрович, который в 1710-х гг. отдает сельцо в качестве приданного за своей дочерью Анастасией при ее браке с лейб-гвардии Преображенского полка прапорщиком князем Алексеем Васильевичем Долгоруковым. Последний в 1720 г. пишет прошение о строительстве в Воронино деревянной церкви Успения Пресвятой Богородицы на месте бывшей Покровской церкви и получает разрешение. Когда была построена церковь, из дела не видно, но по «Ревизским сказкам» 1723-1727 гг. Воронино еще сельцо. Селом оно значится в 1748 г., когда им владеет княгиня Анастасия Владимировна, вдова Алексея Васильевича Долгорукова, дочь Владимира Петровича Шереметева.
<div align="center"><img src="http://pics.livejournal.com/pushkino_2009/pic/0009z9sk" alt="http://pics.livejournal.com/pushkino_2009/pic/0009z9sk" /></div>
</div>
<div align="justify">     В 1768 г. селом владел полковник, граф Василий Иванович Толстой, впоследствии ставший действительным статским советником. Его женою была Александра Ивановна, урожденная Майкова, сестра известного писатели Василия Ивановича Майкова. Их дочь Мария была выдана замуж за Павла Ивановича Фонвизина, писателя, директора Московского университета, брата великого комедиографа Дениса Ивановича Фонвизина. В конце 1760-х гг. усадьба Воронино представляла собой барский дом с флигелями и садом на заднем дворе. Через дорогу от дома стояла деревянная Успенская церковь, за которой начинался большой липовый парк. Южнее парка ютились крестьянские дворы. К концу века Толстой переустраивает усадьбу. На реке Вязь были поставлены запруды, в результате чего образовались три огромных каскадных пруда. От берега вверх поднимались пятью уступами террасы. На нижней находился обложенный дубовым тесом пруд для купания. На средней террасе был построен новый господский дом. Следующий ярус окаймлял его по бокам, образуя, видимо, также смотровые площадки. Весь этот комплекс утопал в зелени сада. По его северной и южной границам в специально вырытых каналах к Вязи струились два водным потока. На верхнем ярусе находились господское кладбище и церковь (в XX в. - деревянная часовня) при нем.</div>
<div align="justify">     В начале XIX в. усадьбой владел Дмитрий Сергеевич Лужин, затем его сын, Иван. <img src="http://pics.livejournal.com/pushkino_2009/pic/0009xrkc" alt="http://pics.livejournal.com/pushkino_2009/pic/0009xrkc" width="169" height="206" />Иван Дмитриевич Лужин в двадцатилетнем возрасте был корнетом из эстандарт-юнкеров лейб-гвардии Конного полка. В 1831 г. он проявил себя в подавлении польского восстания, за что был награжден орденом Владимира 4 степени с бантом и назначен на должность флигель-адъютант Николая I. Через двенадцать лет он исправляющий должность московского обер-полицеймейстера, генерал-майор свиты,в 1854 г. курский, а два года спустя харьковский военный и гражданский губернатор. Перед отъездом из Москвы он продает Воронино А.Н. Верстовскому. <img src="http://pics.livejournal.com/pushkino_2009/pic/0009pwpk" alt="http://pics.livejournal.com/pushkino_2009/pic/0009pwpk" width="152" height="236" />Алексей Николаевич Верстовский родился в имении Селиверстово, близ села Мезинец, Козловского уезда Тамбовской губернии, расположенном на живописном берегу реки Лесной Воронеж. С детства у него проявился талант к музыке, которой он посвятил всю свою жизнь. Среди произведений композитора оперы "Пан Твардовский", "Цыгане", "Аскольдова могила", много романсов и баллад.
В 1890 г. усадьбой владеет дочь статского советника Надежда Николаевна Топорова, в 1911 г. Арманды. Усадебный дом и церковь разобраны на стройматериал в 1930-х гг. От усадьбы сохранились в весьма заросшем состоянии постепенно переходящий в лес парк и пересохший пруд. Кое-где можно отыскать остатки построек.</div>
<div align="justify">     Находившееся на противоположном берегу реки сельцо Малое Воронино в 1677 г. в поместьи окольничего, князя Константина Осиповича Щербатова. Боярин и воевода, Константин Осипович был известен победами над поляками и над сообщниками Стеньки Разина, которых разбил наголову при селе Мурашкине. Он служил также судьей ямского приказа и енисейским воеводой.
<img src="http://pics.livejournal.com/pushkino_2009/pic/0009kq2b" alt="http://pics.livejournal.com/pushkino_2009/pic/0009kq2b" />В 1704 г. Малое Воронино во владении окольничего, князя Юрия Федоровича Щербатова, в 1719 г. советника Филиппа Алексеевича Ягужинского. Во второй половине XVIII в. здесь усадьба с господским домом и небольшим садом из плодовых деревьев, которая принадлежала статской советнице Елене Степановне Хвостовой. В середине XIX в. усадьбой владел Николай Лукич Долгов, представитель купеческого рода, известного по Москве в то время своей благотворительностью. Последний владелец усадьбы московский купец Е.С. Кротов. По местным легендам во время празднеств и гуляний Кротов щедро одаривал местных крестьян, бросая им с балкона господского дома медяки и серебряную мелочь. С приходом новой власти усадебный дом разобран на стройматериал. В некоторых местах сохранились отдельные деревья некогда большого парка. Неподалеку при деревнях Хвостово и Кобылино были имения Гвоздевых и А.Н. Телепневой, приобретенные в начале XX в. у Кротова. Местное предание гласит, что Кротов так назвал деревни в память о любимой лошади. Однако на самом деле это просто веселая выдумка. Эти деревни возникли очень давно и были названы по фамилиям хозяев, известных землевладельцев XV в. Кобылиных и Хвостовых.</div>
&nbsp;
<em>*Энколпионы небольшие коробочки или кресты с мощами святого, предназначенные для ношения на груди. </em>
<strong>Василий Коршун</strong>
Опубликовано: "Пушкинский Вестник" 2005 г. №11(268)
Фото (вверху) передано автору статьи С.В. Демидовым.

View File

@@ -1,50 +0,0 @@
@import '@fontsource/pt-serif/400.css';
@import '@fontsource/pt-serif/400-italic.css';
@import '@fontsource/pt-serif/700.css';
@import '@fontsource/ibm-plex-sans/400.css';
@import '@fontsource/ibm-plex-sans/500.css';
@import '@fontsource/ibm-plex-sans/600.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html { -webkit-text-size-adjust: 100%; }
body {
@apply font-sans bg-paper text-ink antialiased;
}
h1, h2, h3, h4, h5, h6 {
@apply font-serif text-ink;
}
a {
@apply text-accent hover:underline underline-offset-2;
}
}
@layer components {
.prose-article {
@apply font-serif text-[1.05rem] leading-[1.7] text-ink;
}
.prose-article p { @apply my-4; }
.prose-article h1, .prose-article h2, .prose-article h3 {
@apply font-serif font-bold mt-8 mb-3;
}
.prose-article h1 { @apply text-2xl; }
.prose-article h2 { @apply text-xl; }
.prose-article h3 { @apply text-lg; }
.prose-article img {
@apply my-4 max-w-full h-auto rounded shadow-sm border border-rule;
}
.prose-article a { @apply text-accent underline underline-offset-2; }
.prose-article ul { @apply list-disc list-inside my-4; }
.prose-article ol { @apply list-decimal list-inside my-4; }
.prose-article blockquote {
@apply border-l-2 border-accent pl-4 italic my-4 text-muted;
}
.prose-article strong { @apply font-bold; }
.prose-article em { @apply italic; }
.rule-line {
@apply border-t border-rule;
}
}

View File

@@ -0,0 +1,137 @@
---
import '../styles/global.css';
import Header from '../components/Header.astro';
import Sidebar from '../components/Sidebar.astro';
import Footer from '../components/Footer.astro';
import CookieConsent from '../components/CookieConsent.astro';
import Analytics from '../components/Analytics.astro';
import { SITE_TITLE, SITE_DESCRIPTION, SITE_URL, SITE_LANG } from '../consts';
interface Props {
title?: string;
description?: string;
ogImage?: string;
noSidebar?: boolean;
canonical?: string;
}
const {
title,
description = SITE_DESCRIPTION,
ogImage = '/og-image.png',
noSidebar = false,
canonical,
} = Astro.props;
const fullTitle = title ? `${title} — ${SITE_TITLE}` : SITE_TITLE;
const url = canonical ?? new URL(Astro.url.pathname, SITE_URL).toString();
const ogImageUrl = new URL(ogImage, SITE_URL).toString();
const jsonLd = {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'WebSite',
'@id': `${SITE_URL}/#website`,
url: `${SITE_URL}/`,
name: SITE_TITLE,
description: SITE_DESCRIPTION,
inLanguage: 'ru-RU',
publisher: { '@id': `${SITE_URL}/#publisher` },
// creator — тех-партнёр (hhivp.com). Entity-сигнал для AI Overviews.
creator: {
'@type': 'Organization',
'@id': 'https://hhivp.com/#organization',
name: 'ООО «АйТи Решения»',
url: 'https://hhivp.com/',
sameAs: ['https://hhivp.com'],
},
},
{
'@type': 'NewsMediaOrganization',
'@id': `${SITE_URL}/#publisher`,
name: SITE_TITLE,
url: `${SITE_URL}/`,
logo: {
'@type': 'ImageObject',
url: `${SITE_URL}/favicon.svg`,
},
description:
'Краеведческое медиа: история, новости и фотолетопись подмосковного города Пушкино — от давних времён до наших дней.',
},
],
};
---
<!doctype html>
<html lang={SITE_LANG}>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{fullTitle}</title>
<meta name="description" content={description} />
<link rel="canonical" href={url} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="alternate" type="application/rss+xml" title="История города Пушкино — RSS" href="/feed.xml" />
<meta property="og:type" content="website" />
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
<meta property="og:url" content={url} />
<meta property="og:image" content={ogImageUrl} />
<meta property="og:locale" content="ru_RU" />
<meta property="og:site_name" content={SITE_TITLE} />
<meta name="twitter:card" content="summary_large_image" />
<script type="application/ld+json" is:inline set:html={JSON.stringify(jsonLd)} />
<!--
Speculation Rules API (Chromium 122+) — prerender same-origin pages on
hover/pointerdown for near-instant navigation.
-->
<script type="speculationrules" is:inline set:html={JSON.stringify({
prerender: [
{
where: {
and: [
{ href_matches: '/*' },
{ not: { href_matches: '/assets/*' } },
{ not: { href_matches: '/uploads/*' } },
{ not: { href_matches: '/api/*' } },
{ not: { href_matches: '/sitemap*' } },
{ not: { href_matches: '/feed*' } },
{ not: { href_matches: '/llms*' } }
]
},
eagerness: 'moderate'
}
]
})} />
<Analytics />
</head>
<body>
<Header />
<div class="container">
{noSidebar ? (
<main class="layout-single">
<slot />
</main>
) : (
<div class="layout-grid">
<main><slot /></main>
<Sidebar />
</div>
)}
</div>
<Footer />
<CookieConsent />
</body>
</html>
<style>
.layout-single {
max-width: var(--reading-max);
margin: 2rem auto;
}
</style>

35
src/lib/extract.ts Normal file
View File

@@ -0,0 +1,35 @@
/** Утилиты: извлечение превью-картинки и текстового excerpt из HTML-тела поста. */
const IMG_RE = /<img[^>]+src=["']([^"']+)["'][^>]*>/i;
const TAG_RE = /<[^>]+>/g;
const WS_RE = /\s+/g;
export function firstImage(html: string): string | null {
const m = IMG_RE.exec(html);
return m ? m[1] : null;
}
export function plainText(html: string, max = 320): string {
const txt = html
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<!--[\s\S]*?-->/g, ' ')
.replace(TAG_RE, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(WS_RE, ' ')
.trim();
if (txt.length <= max) return txt;
return txt.slice(0, max).replace(/\s+\S*$/, '') + '…';
}
export function formatDateRu(d: Date): string {
return d.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
}

120
src/lib/rss-helpers.ts Normal file
View File

@@ -0,0 +1,120 @@
import sanitizeHtml from 'sanitize-html';
import { SITE_URL } from '../consts';
/** Sanitize и нормализация HTML тела поста для RSS `<content:encoded>`. */
export function sanitizeForRss(html: string): string {
const absolutized = html.replace(/(src|href)=["']\/uploads\//g, `$1="${SITE_URL}/uploads/`);
return sanitizeHtml(absolutized, {
allowedTags: [
'p', 'br', 'strong', 'em', 'b', 'i', 'u', 's',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'a', 'img',
'ul', 'ol', 'li',
'blockquote', 'pre', 'code',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
'div', 'span', 'hr',
],
allowedAttributes: {
a: ['href', 'title', 'rel', 'target'],
img: ['src', 'alt', 'title', 'width', 'height'],
div: ['align'],
span: ['align'],
p: ['align'],
td: ['colspan', 'rowspan'],
th: ['colspan', 'rowspan'],
},
transformTags: {
a: (tagName, attribs) => ({
tagName,
attribs: {
...attribs,
target: '_blank',
rel: 'noopener noreferrer',
},
}),
},
allowedSchemes: ['http', 'https', 'mailto'],
});
}
/** Экранировать строку для XML-атрибутов и текстовых нод. */
export function xmlEscape(s: string): string {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/** Завернуть в CDATA, нейтрализовав `]]>` внутри. */
export function cdata(s: string): string {
return `<![CDATA[${String(s).replace(/]]>/g, ']]]]><![CDATA[>')}]]>`;
}
/** Текстовое описание из HTML (для `<description>`). */
export function plainTextExcerpt(html: string, max = 500): string {
const txt = html
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/\s+/g, ' ')
.trim();
if (txt.length <= max) return txt;
return txt.slice(0, max).replace(/\s+\S*$/, '') + '…';
}
/** Построить полный RSS 2.0 XML с CDATA-завёрнутым content:encoded. */
export function buildRss(opts: {
title: string;
description: string;
selfUrl: string;
language: string;
items: Array<{
title: string;
link: string;
pubDate: Date;
description: string;
contentHtml: string;
author: string;
categories: string[];
}>;
}): string {
const { title, description, selfUrl, language, items } = opts;
const lastBuild = new Date().toUTCString();
const itemsXml = items
.map((it) => ` <item>
<title>${cdata(it.title)}</title>
<link>${xmlEscape(it.link)}</link>
<guid isPermaLink="true">${xmlEscape(it.link)}</guid>
<pubDate>${it.pubDate.toUTCString()}</pubDate>
<dc:creator>${cdata(it.author)}</dc:creator>
${it.categories.map((c) => ` <category>${cdata(c)}</category>`).join('\n')}
<description>${cdata(it.description)}</description>
<content:encoded>${cdata(it.contentHtml)}</content:encoded>
</item>`)
.join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${cdata(title)}</title>
<link>${xmlEscape(SITE_URL + '/')}</link>
<atom:link href="${xmlEscape(selfUrl)}" rel="self" type="application/rss+xml" />
<description>${cdata(description)}</description>
<language>${language}</language>
<lastBuildDate>${lastBuild}</lastBuildDate>
<ttl>60</ttl>
${itemsXml}
</channel>
</rss>
`;
}

View File

@@ -1,12 +0,0 @@
import React from 'react';
import { createRoot, hydrateRoot } from 'react-dom/client';
import App from './App';
import './index.css';
const container = document.getElementById('root');
if (container.hasChildNodes()) {
hydrateRoot(container, <App />);
} else {
createRoot(container).render(<App />);
}

23
src/pages/404.astro Normal file
View File

@@ -0,0 +1,23 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="Не найдено" description="Страница не найдена">
<div class="not-found">
<h1>404</h1>
<p>Такой страницы здесь нет. Возможно, она переехала или давно удалена.</p>
<p><a href="/">← На главную</a></p>
</div>
</BaseLayout>
<style>
.not-found { text-align: center; padding: 3rem 1rem; max-width: 520px; margin: 0 auto; }
.not-found h1 {
font-family: var(--font-serif);
font-size: clamp(3rem, 8vw, 5rem);
color: var(--accent);
margin: 0;
}
.not-found p { color: var(--muted); margin: 0.5rem 0; }
.not-found a { color: var(--accent); }
</style>

View File

@@ -1,20 +0,0 @@
import React from 'react';
import { postsList, categories } from '../content';
import PostCard from '../components/PostCard';
export default function Category({ slug }) {
const cat = categories.find((c) => c.slug === slug);
const filtered = postsList.filter((p) => p.categorySlugs?.includes(slug));
return (
<div>
<h1 className="font-serif text-2xl font-bold mb-2">
Категория: {cat?.name || slug}
</h1>
<div className="text-xs text-muted mb-6 pb-4 border-b border-rule">
{filtered.length} {filtered.length === 1 ? 'запись' : filtered.length < 5 ? 'записи' : 'записей'}
</div>
{filtered.map((p) => <PostCard key={p.slug} post={p} />)}
{filtered.length === 0 && <p className="text-muted">В этой категории пока нет записей.</p>}
</div>
);
}

View File

@@ -1,13 +0,0 @@
import React from 'react';
import { postsList } from '../content';
import PostCard from '../components/PostCard';
export default function Home() {
return (
<div>
{postsList.map((p) => (
<PostCard key={p.slug} post={p} />
))}
</div>
);
}

View File

@@ -1,54 +0,0 @@
import React, { useEffect, useState } from 'react';
function formatDate(s) {
const d = new Date(s);
if (Number.isNaN(d.getTime())) return s;
return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
export default function News() {
const [state, setState] = useState({ loading: true, items: [], error: null });
useEffect(() => {
fetch('/api/news.json', { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
.then((data) => setState({ loading: false, items: data.items || [], error: null }))
.catch((e) => setState({ loading: false, items: [], error: e.message }));
}, []);
return (
<div>
<h1 className="font-serif text-3xl font-bold mb-2">Новости</h1>
<p className="text-xs text-muted mb-6 pb-4 border-b border-rule">
Агрегатор новостей о Пушкино из внешних источников. Обновляется автоматически.
</p>
{state.loading && <p className="text-muted">Загружаем новости</p>}
{state.error && (
<p className="text-muted">
Не удалось загрузить новости. Загляните позже.
</p>
)}
{!state.loading && !state.error && state.items.length === 0 && (
<p className="text-muted">Пока нет новостей.</p>
)}
<ul className="space-y-6">
{state.items.map((item, i) => (
<li key={item.guid || item.link || i} className="border-b border-rule pb-4 last:border-0">
<a href={item.link} target="_blank" rel="noopener noreferrer" className="font-serif text-lg font-bold text-ink hover:text-accent no-underline">
{item.title}
</a>
<div className="text-xs text-muted mt-1">
{item.source && <span>{item.source}</span>}
{item.pubDate && <span> · {formatDate(item.pubDate)}</span>}
</div>
{item.description && (
<p className="text-sm text-ink/80 mt-2">{item.description}</p>
)}
</li>
))}
</ul>
</div>
);
}

View File

@@ -1,11 +0,0 @@
import React from 'react';
export default function NotFound() {
return (
<div className="py-12 text-center">
<h1 className="font-serif text-4xl font-bold mb-2">404</h1>
<p className="text-muted mb-6">Такой страницы здесь нет.</p>
<a href="/" className="text-accent">На главную</a>
</div>
);
}

View File

@@ -1,12 +0,0 @@
import React from 'react';
export default function Page({ page }) {
return (
<article>
<h1 className="font-serif text-3xl font-bold leading-tight mb-6 pb-4 border-b border-rule">
{page.title}
</h1>
<div className="prose-article" dangerouslySetInnerHTML={{ __html: page.html }} />
</article>
);
}

View File

@@ -1,30 +0,0 @@
import React from 'react';
function formatDate(s) {
const d = new Date(s.replace(' ', 'T'));
if (Number.isNaN(d.getTime())) return s;
return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' });
}
export default function Post({ post }) {
return (
<article>
<h1 className="font-serif text-3xl font-bold leading-tight mb-2">{post.title}</h1>
<div className="text-xs text-muted mb-6 pb-4 border-b border-rule">
<time dateTime={post.date}>{formatDate(post.date)}</time>
{post.categories?.length > 0 && (
<>
{' · '}
{post.categories.map((c, i) => (
<span key={c}>
{i > 0 && ', '}
<a href={`/cat/${post.categorySlugs[i]}/`} className="text-muted hover:text-accent">{c}</a>
</span>
))}
</>
)}
</div>
<div className="prose-article" dangerouslySetInnerHTML={{ __html: post.html }} />
</article>
);
}

95
src/pages/[slug].astro Normal file
View File

@@ -0,0 +1,95 @@
---
import { getCollection, render } from 'astro:content';
import BaseLayout from '../layouts/BaseLayout.astro';
import { formatDateRu } from '../lib/extract';
export async function getStaticPaths() {
const [posts, pages] = await Promise.all([
getCollection('posts'),
getCollection('pages'),
]);
const out: { params: { slug: string }; props: { kind: 'post' | 'page'; entry: any } }[] = [];
for (const p of posts) {
out.push({ params: { slug: p.data.slug }, props: { kind: 'post', entry: p } });
}
for (const p of pages) {
if (p.data.slug === 'forum') continue;
out.push({ params: { slug: p.data.slug }, props: { kind: 'page', entry: p } });
}
return out;
}
const { kind, entry } = Astro.props;
const { Content } = await render(entry);
---
<BaseLayout title={entry.data.title} description={entry.data.description ?? ''}>
<article class="article">
<header class="article-head">
<h1>{entry.data.title}</h1>
{kind === 'post' && entry.data.pubDate && (
<div class="meta">
<time datetime={entry.data.pubDate.toISOString()}>
<span class="date-mark">●</span> {formatDateRu(entry.data.pubDate)}
</time>
{entry.data.categories?.filter((_: string, i: number) => entry.data.categorySlugs?.[i] !== 'main').length > 0 && (
<span class="cats">
{entry.data.categories.map((c: string, i: number) => {
const slug = entry.data.categorySlugs?.[i] ?? c;
if (slug === 'main') return null;
return (
<>
<a href={`/cat/${slug}/`}>{c}</a>
</>
);
})}
</span>
)}
</div>
)}
</header>
<div class="prose">
<Content />
</div>
{kind === 'post' && (
<footer class="article-foot">
<a class="back" href="/">← К списку записей</a>
</footer>
)}
</article>
</BaseLayout>
<style>
.article { max-width: var(--reading-max); }
.article-head {
border-bottom: 1px solid var(--rule);
padding-bottom: 1rem;
margin-bottom: 1.5rem;
}
.article-head h1 {
font-family: var(--font-serif);
font-size: clamp(1.7rem, 3.5vw, 2.4rem);
font-weight: 700;
margin: 0 0 0.4rem;
line-height: 1.15;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 0.7rem;
align-items: baseline;
font-size: 0.85rem;
color: var(--muted);
}
.date-mark { color: var(--accent); margin-right: 0.2rem; }
.cats a { color: var(--ink-soft); text-decoration: none; border-bottom: 1px dotted var(--rule-strong); }
.cats a:hover { color: var(--accent); }
.sep { color: var(--rule-strong); margin: 0 0.2rem; }
.article-foot {
margin-top: 2.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--rule);
}
.back { font-family: var(--font-sans); font-size: 0.92rem; color: var(--accent); text-decoration: none; }
.back:hover { color: var(--accent-soft); }
</style>

View File

@@ -0,0 +1,49 @@
---
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import PostCard from '../../components/PostCard.astro';
import { plural } from '../../consts';
export async function getStaticPaths() {
const all = await getCollection('posts');
const slugs = new Map<string, string>(); // slug -> display name
for (const p of all) {
p.data.categorySlugs.forEach((s, i) => {
if (!slugs.has(s)) slugs.set(s, p.data.categories[i] ?? s);
});
}
return [...slugs.entries()].map(([slug, name]) => ({
params: { slug },
props: { catSlug: slug, catName: name },
}));
}
const { catSlug, catName } = Astro.props;
const all = await getCollection('posts');
const posts = all
.filter((p) => p.data.categorySlugs.includes(catSlug))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
---
<BaseLayout title={`Категория: ${catName}`} description={`Записи в рубрике «${catName}»`}>
<div class="cat-head">
<h1>Категория: {catName}</h1>
<p class="meta">
{posts.length}&nbsp;{plural(posts.length, ['запись', 'записи', 'записей'])}
&middot;
<a href={`/cat/${catSlug}/feed.xml`}>RSS этой рубрики</a>
</p>
</div>
<div class="post-list">
{posts.map((p) => <PostCard post={p} />)}
{posts.length === 0 && <p class="empty">В этой рубрике пока нет записей.</p>}
</div>
</BaseLayout>
<style>
.cat-head { border-bottom: 1px solid var(--rule); padding-bottom: 1rem; margin-bottom: 1rem; }
.cat-head h1 { font-family: var(--font-serif); font-size: 1.8rem; margin: 0; }
.cat-head .meta { margin: 0.5rem 0 0; font-size: 0.85rem; color: var(--muted); }
.cat-head a { color: var(--accent); }
.empty { color: var(--muted); margin: 1rem 0; }
</style>

View File

@@ -0,0 +1,58 @@
import { getCollection } from 'astro:content';
import {
SITE_TITLE,
SITE_URL,
SITE_LANG,
RSS_CUTOFF,
RSS_LIMIT,
} from '../../../consts';
import { buildRss, plainTextExcerpt, sanitizeForRss } from '../../../lib/rss-helpers';
export async function getStaticPaths() {
const all = await getCollection('posts');
const slugs = new Map<string, string>();
for (const p of all) {
p.data.categorySlugs.forEach((s, i) => {
if (!slugs.has(s)) slugs.set(s, p.data.categories[i] ?? s);
});
}
return [...slugs.entries()].map(([slug, name]) => ({
params: { slug },
props: { catSlug: slug, catName: name },
}));
}
export const GET = async ({ props }: { props: { catSlug: string; catName: string } }) => {
const { catSlug, catName } = props;
const all = await getCollection('posts');
const items = all
.filter((p) => p.data.categorySlugs.includes(catSlug) && p.data.pubDate >= RSS_CUTOFF)
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())
.slice(0, RSS_LIMIT)
.map((p) => {
const link = `${SITE_URL}/${p.data.slug}/`;
const contentHtml = sanitizeForRss(p.body ?? '');
const description = p.data.description || plainTextExcerpt(contentHtml, 500);
return {
title: p.data.title,
link,
pubDate: p.data.pubDate,
description,
contentHtml,
author: p.data.author,
categories: [catName],
};
});
const xml = buildRss({
title: `${SITE_TITLE}${catName}`,
description: `Рубрика «${catName}»`,
selfUrl: `${SITE_URL}/cat/${catSlug}/feed.xml`,
language: SITE_LANG,
items,
});
return new Response(xml, {
headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' },
});
};

44
src/pages/feed.xml.ts Normal file
View File

@@ -0,0 +1,44 @@
import { getCollection } from 'astro:content';
import {
SITE_TITLE,
SITE_DESCRIPTION,
SITE_URL,
SITE_LANG,
RSS_CUTOFF,
RSS_LIMIT,
} from '../consts';
import { buildRss, plainTextExcerpt, sanitizeForRss } from '../lib/rss-helpers';
export const GET = async () => {
const all = await getCollection('posts');
const items = all
.filter((p) => p.data.pubDate >= RSS_CUTOFF)
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())
.slice(0, RSS_LIMIT)
.map((p) => {
const link = `${SITE_URL}/${p.data.slug}/`;
const contentHtml = sanitizeForRss(p.body ?? '');
const description = p.data.description || plainTextExcerpt(contentHtml, 500);
return {
title: p.data.title,
link,
pubDate: p.data.pubDate,
description,
contentHtml,
author: p.data.author,
categories: p.data.categories,
};
});
const xml = buildRss({
title: SITE_TITLE,
description: SITE_DESCRIPTION,
selfUrl: `${SITE_URL}/feed.xml`,
language: SITE_LANG,
items,
});
return new Response(xml, {
headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' },
});
};

59
src/pages/index.astro Normal file
View File

@@ -0,0 +1,59 @@
---
import { getCollection } from 'astro:content';
import BaseLayout from '../layouts/BaseLayout.astro';
import PostCard from '../components/PostCard.astro';
const allPosts = await getCollection('posts');
// Скрываем служебные (hideFromList) из ленты главной.
const visible = allPosts.filter((p) => !p.data.hideFromList);
// Pinned featured-посты идут сверху, в порядке pubDate desc внутри группы.
const featured = visible
.filter((p) => p.data.featured)
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
const rest = visible
.filter((p) => !p.data.featured)
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
const lead = featured[0];
const otherFeatured = featured.slice(1);
---
<BaseLayout>
{lead && (
<section class="featured-section">
<PostCard post={lead} featured />
</section>
)}
{otherFeatured.length > 0 && (
<section class="more-history">
<h2 class="section-title">Ещё из истории</h2>
{otherFeatured.map((p) => <PostCard post={p} />)}
</section>
)}
{rest.length > 0 && (
<section class="chronicle">
<h2 class="section-title">Хроника</h2>
{rest.map((p) => <PostCard post={p} />)}
</section>
)}
</BaseLayout>
<style>
.featured-section { margin-bottom: 1rem; }
.more-history, .chronicle { margin-top: 1.5rem; }
.section-title {
font-family: var(--font-serif);
font-size: 1rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--accent);
margin: 0 0 0.8rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--rule-strong);
}
</style>

94
src/pages/news.astro Normal file
View File

@@ -0,0 +1,94 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout
title="Новости"
description="Агрегатор новостей о Пушкино из внешних источников."
>
<div class="news-head">
<h1>Новости</h1>
<p class="meta">
Агрегатор новостей о Пушкино из внешних источников. Обновляется автоматически каждый час.
</p>
</div>
<div id="news-list" class="news-list">
<p class="loading">Загружаем новости…</p>
</div>
</BaseLayout>
<style>
.news-head { border-bottom: 1px solid var(--rule); padding-bottom: 1rem; margin-bottom: 1.5rem; }
.news-head h1 { font-family: var(--font-serif); font-size: 1.8rem; margin: 0; }
.news-head .meta { margin: 0.5rem 0 0; color: var(--muted); font-size: 0.9rem; }
.news-list { display: flex; flex-direction: column; gap: 1.5rem; }
.news-item {
padding-bottom: 1rem;
border-bottom: 1px solid var(--rule);
}
.news-item:last-child { border-bottom: 0; }
.news-item .ni-title {
font-family: var(--font-serif);
font-size: 1.2rem;
font-weight: 700;
line-height: 1.3;
display: inline-block;
color: var(--ink);
text-decoration: none;
}
.news-item .ni-title:hover { color: var(--accent); }
.news-item .ni-meta {
margin: 0.25rem 0 0.5rem;
font-size: 0.8rem;
color: var(--muted);
}
.news-item .ni-desc {
font-family: var(--font-serif);
font-size: 0.96rem;
color: var(--ink-soft);
margin: 0;
}
.loading { color: var(--muted); }
.empty, .error { color: var(--muted); font-style: italic; }
</style>
<script is:inline>
(async function () {
const list = document.getElementById('news-list');
if (!list) return;
function fmtDate(s) {
const d = new Date(s);
if (Number.isNaN(d.getTime())) return s ?? '';
return d.toLocaleDateString('ru-RU', {
day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit'
});
}
function esc(s) {
const div = document.createElement('div');
div.textContent = String(s ?? '');
return div.innerHTML;
}
try {
const r = await fetch('/api/news.json', { cache: 'no-store' });
if (!r.ok) throw new Error('HTTP ' + r.status);
const data = await r.json();
const items = data.items || [];
if (items.length === 0) {
list.innerHTML = '<p class="empty">Пока нет внешних новостей.</p>';
return;
}
list.innerHTML = items.map((it) => `
<article class="news-item">
<a class="ni-title" href="${esc(it.link)}" target="_blank" rel="noopener noreferrer">${esc(it.title)}</a>
<div class="ni-meta">
${it.source ? `<span>${esc(it.source)}</span>` : ''}
${it.pubDate ? ` · <time datetime="${esc(it.pubDate)}">${esc(fmtDate(it.pubDate))}</time>` : ''}
</div>
${it.description ? `<p class="ni-desc">${esc(it.description)}</p>` : ''}
</article>
`).join('');
} catch (e) {
list.innerHTML = '<p class="error">Не удалось загрузить новости. Загляните позже.</p>';
}
})();
</script>

57
src/pages/privacy.astro Normal file
View File

@@ -0,0 +1,57 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="Политика конфиденциальности" description="Политика обработки данных pushkinohistory.ru" noSidebar>
<article class="prose">
<h1>Политика конфиденциальности</h1>
<p><em>В соответствии с Федеральным законом от 27.07.2006 № 152-ФЗ «О персональных данных».</em></p>
<h2>1. Какие данные мы обрабатываем</h2>
<p>Сайт pushkinohistory.ru — статический проект без регистрации, форм обратной связи и личных кабинетов. Мы не запрашиваем у вас имя, телефон, электронную почту и иные персональные данные напрямую.</p>
<p>Автоматически при посещении сайта собираются обезличенные технические данные: IP-адрес, тип браузера, версия операционной системы, источник перехода, посещённые страницы. Эти данные используются исключительно для анализа аудитории и улучшения сайта.</p>
<h2>2. Cookies и системы аналитики</h2>
<p>Если вы согласились в баннере, на сайте работают:</p>
<ul>
<li><strong>Яндекс.Метрика</strong> — счётчик посещаемости с включённым «Вебвизором». Политика — <a href="https://yandex.ru/legal/confidential/" target="_blank" rel="noopener noreferrer">yandex.ru/legal/confidential</a>.</li>
<li><strong>Google Analytics</strong> — обезличенная статистика посещений. Политика — <a href="https://policies.google.com/privacy" target="_blank" rel="noopener noreferrer">policies.google.com/privacy</a>.</li>
</ul>
<p>Согласие сохраняется в браузере (localStorage + cookie <code>ph-consent</code>) на 12 месяцев. До получения согласия скрипты аналитики не выполняются.</p>
<h2>3. Отозвать согласие</h2>
<p>Вы можете отозвать согласие на использование cookies одним кликом:</p>
<p><button type="button" id="cc-revoke" class="cc-revoke">Отозвать согласие</button></p>
<h2>4. Хранение и передача</h2>
<p>Обезличенные данные о посещениях хранятся в сервисах Яндекс.Метрика и Google Analytics в течение установленных ими сроков. Мы не передаём данные третьим лицам сверх указанных систем аналитики.</p>
<h2>5. Изменения политики</h2>
<p>Действующая редакция всегда доступна по этой странице. Существенные изменения отмечаются датой обновления.</p>
<p><em>Обновлено: 21 мая 2026 г.</em></p>
</article>
</BaseLayout>
<style>
.prose h1 { font-size: clamp(1.7rem, 3.5vw, 2.2rem); margin: 0 0 1rem; }
.prose h2 { font-size: 1.2rem; margin: 1.8rem 0 0.5rem; }
.cc-revoke {
font-family: var(--font-sans);
font-size: 0.95rem;
padding: 0.55rem 1rem;
border: 1px solid var(--rule-strong);
background: var(--paper);
cursor: pointer;
color: var(--ink);
}
.cc-revoke:hover { color: var(--accent); border-color: var(--accent); }
</style>
<script is:inline>
document.getElementById('cc-revoke')?.addEventListener('click', () => {
try { localStorage.setItem('ph-consent', 'deny'); } catch {}
const exp = new Date(Date.now() + 365 * 24 * 3600 * 1000).toUTCString();
document.cookie = `ph-consent=deny; expires=${exp}; path=/; SameSite=Lax`;
alert('Согласие отозвано. Скрипты аналитики не загружаются. Перезагрузите страницу, чтобы изменения вступили в силу.');
});
</script>

29
src/pages/sitemap.txt.ts Normal file
View File

@@ -0,0 +1,29 @@
import { getCollection } from 'astro:content';
import { SITE_URL } from '../consts';
export const GET = async () => {
const [posts, pages] = await Promise.all([
getCollection('posts'),
getCollection('pages'),
]);
const urls = [
`${SITE_URL}/`,
`${SITE_URL}/news/`,
`${SITE_URL}/privacy/`,
];
for (const p of pages) {
if (p.data.slug === 'forum') continue;
urls.push(`${SITE_URL}/${p.data.slug}/`);
}
for (const p of posts) {
urls.push(`${SITE_URL}/${p.data.slug}/`);
}
// categories
const cats = new Set<string>();
for (const p of posts) p.data.categorySlugs.forEach((s) => cats.add(s));
for (const c of cats) urls.push(`${SITE_URL}/cat/${c}/`);
return new Response(urls.join('\n') + '\n', {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
};

155
src/styles/global.css Normal file
View File

@@ -0,0 +1,155 @@
@import '@fontsource/pt-serif/400.css';
@import '@fontsource/pt-serif/400-italic.css';
@import '@fontsource/pt-serif/700.css';
@import '@fontsource/ibm-plex-sans/400.css';
@import '@fontsource/ibm-plex-sans/500.css';
@import '@fontsource/ibm-plex-sans/600.css';
:root {
--paper: #f4ecdb;
--paper-deep: #ead9b9;
--ink: #1f1a15;
--ink-soft: #3a342c;
--muted: #5b554b;
--accent: #8a3a14;
--accent-soft: #b56b3a;
--rule: #c9be9b;
--rule-strong: #8c7e57;
--c-today: #4a6a35;
--c-tech: #6e5a30;
--font-serif: "PT Serif", Georgia, "Times New Roman", serif;
--font-sans: "IBM Plex Sans", system-ui, -apple-system, sans-serif;
--content-max: 1200px;
--reading-max: 740px;
color-scheme: light;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: var(--font-sans);
background: var(--paper);
color: var(--ink);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1.55;
background-image:
radial-gradient(circle at 20% 0%, rgba(184, 145, 80, 0.06), transparent 60%),
radial-gradient(circle at 80% 100%, rgba(138, 58, 20, 0.04), transparent 50%);
background-attachment: fixed;
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-serif);
color: var(--ink);
line-height: 1.2;
margin: 0;
}
a {
color: var(--accent);
text-decoration-thickness: 1px;
text-underline-offset: 2px;
}
a:hover { color: var(--accent-soft); }
img, svg { max-width: 100%; height: auto; }
hr.rule {
border: 0;
border-top: 1px solid var(--rule);
margin: 1.5rem 0;
}
hr.rule-strong {
border: 0;
border-top: 1px solid var(--rule-strong);
margin: 1.5rem 0;
}
/* Container/layout */
.container {
max-width: var(--content-max);
margin: 0 auto;
padding: 0 1.25rem;
}
.layout-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 2.5rem;
margin-top: 2rem;
}
@media (max-width: 920px) {
.layout-grid { grid-template-columns: 1fr; }
}
/* Article body styles */
.prose {
font-family: var(--font-serif);
font-size: 1.07rem;
line-height: 1.75;
color: var(--ink);
max-width: var(--reading-max);
}
.prose p { margin: 1rem 0; }
.prose h1, .prose h2, .prose h3 { font-family: var(--font-serif); font-weight: 700; margin: 1.8rem 0 0.6rem; }
.prose h1 { font-size: 1.7rem; }
.prose h2 { font-size: 1.4rem; }
.prose h3 { font-size: 1.2rem; }
.prose img {
margin: 1.2rem auto;
display: block;
border: 1px solid var(--rule);
padding: 6px;
background: var(--paper);
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.prose blockquote {
border-left: 3px solid var(--accent);
padding-left: 1rem;
margin: 1.2rem 0;
color: var(--muted);
font-style: italic;
}
.prose ul, .prose ol { padding-left: 1.5rem; margin: 1rem 0; }
.prose li { margin: 0.4rem 0; }
.prose a { color: var(--accent); }
.prose [align="center"] { text-align: center; }
.prose [align="justify"] { text-align: justify; hyphens: auto; }
/* Sidebar boxes */
.sidebar {
display: flex;
flex-direction: column;
gap: 1.5rem;
font-size: 0.92rem;
}
.box {
background: rgba(255, 252, 244, 0.5);
border: 1px solid var(--rule);
padding: 0.9rem 1rem;
}
.box h3 {
font-family: var(--font-serif);
font-size: 1rem;
font-weight: 700;
margin: 0 0 0.6rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--rule);
letter-spacing: 0.01em;
}
.box ul { list-style: none; margin: 0; padding: 0; }
.box li { padding: 0.25rem 0; }
.box li + li { border-top: 1px dotted var(--rule); }
/* Visually hidden */
.sr-only {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0,0,0,0); white-space: nowrap; border: 0;
}

View File

@@ -1,20 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,jsx}'],
theme: {
extend: {
fontFamily: {
serif: ['"PT Serif"', 'Georgia', 'serif'],
sans: ['"IBM Plex Sans"', 'system-ui', 'sans-serif'],
},
colors: {
paper: '#f8f4ec',
ink: '#1f1a15',
accent: '#7a3b14',
muted: '#5b554b',
rule: '#d6cdb8',
},
},
},
plugins: [],
};

3
tsconfig.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "astro/tsconfigs/strict"
}

View File

@@ -1,14 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
open: false,
},
build: {
outDir: 'dist',
sourcemap: false,
},
});