Compare commits
28 Commits
vite-react
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71b37935c2 | ||
|
|
93c16e9ada | ||
|
|
f006eedf71 | ||
|
|
d27b2f6719 | ||
|
|
c2704ec3a1 | ||
|
|
a32346fc3d | ||
|
|
fb613d0b02 | ||
|
|
708ee41596 | ||
|
|
bdccf62cee | ||
|
|
5689940853 | ||
|
|
3a2d66eb6c | ||
|
|
f4923fd6b4 | ||
|
|
e79fdf1c59 | ||
|
|
29a5faadee | ||
|
|
3868774a2b | ||
|
|
3d42a588d4 | ||
|
|
182040c633 | ||
|
|
98c91f605b | ||
|
|
3104eaf1ce | ||
| d2bd3647d4 | |||
| ffb3f94a57 | |||
|
|
cf17c6e432 | ||
|
|
78fedc59cf | ||
|
|
87b44d07c6 | ||
|
|
0e758f3a1a | ||
|
|
5dddb68803 | ||
|
|
2edb852da3 | ||
|
|
c65e07cd98 |
78
.gitea/workflows/deploy.yml
Normal file
78
.gitea/workflows/deploy.yml
Normal 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
|
||||||
72
.gitea/workflows/security.yml
Normal file
72
.gitea/workflows/security.yml
Normal 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
59
.github/workflows/security.yml
vendored
Normal 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
28
.gitignore
vendored
@@ -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/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
.astro/
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
.vite/
|
|
||||||
data/news.json
|
# generated content (исходник в БД и/или в посты-md)
|
||||||
scripts/posts-raw.jsonl
|
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
49
.gitleaks.toml
Normal 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
195
CLAUDE.md
@@ -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
|
- **Astro 5** + Content Collections + markdown
|
||||||
- PT Serif (заголовки/основной текст) + IBM Plex Sans (UI)
|
- **nginx:1.29-alpine** в runtime-контейнере (статика + bind-mount для агрегатора новостей)
|
||||||
- Express 4 + compression (runtime сервер, отдаёт prerendered HTML + `/api/news.json` + `/feed/`)
|
- **sanitize-html** — очистка тела поста для RSS `<content:encoded>` (с CDATA)
|
||||||
- Puppeteer для prerender (chromium в build-стадии)
|
- **fast-xml-parser** — изолированно в `scripts/` (только для cron-агрегатора)
|
||||||
- fast-xml-parser для агрегатора внешних RSS
|
- **sharp** (devDep, опц.) — генерация OG-image PNG из SVG
|
||||||
|
- **PT Serif** (заголовки/тело статьи) + **IBM Plex Sans** (UI)
|
||||||
|
- **@astrojs/sitemap** — `sitemap-index.xml` автоматически
|
||||||
|
|
||||||
## Структура
|
## Структура
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
App.jsx # клиентский роутер (window.history + popstate), 301-карта oldSlug → newSlug
|
├── content/
|
||||||
components/ # Header, Sidebar, Footer, PostCard
|
│ ├── posts/*.md (7 постов, мигрированы из WP DB ph_posts)
|
||||||
content/ # JSON-контент: posts, pages, partners, ads, transport, feeds
|
│ ├── pages/*.md (4 страницы: Главная-приветствие, История, Фото, Форум)
|
||||||
pages/ # Home, Post, Page, Category, News, NotFound
|
│ └── _categories.json (через categorySlugs в frontmatter)
|
||||||
server/index.js # Express: /api/health, /api/news.json, /uploads/, /feed/, статика
|
├── 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/
|
scripts/
|
||||||
convert_posts.py # WP DB → src/content/{posts,pages}.json
|
├── convert_posts.py (WP DB → src/content/posts.json + pages.json, fix WP-resized URL)
|
||||||
build-rss.js # генератор IPB-совместимого RSS (full content в CDATA)
|
├── convert_to_markdown.py (posts.json → src/content/posts/*.md с frontmatter)
|
||||||
build-sitemap.js # sitemap.xml + robots.txt
|
├── pull-external-rss.mjs (cron на хосте: feeds.json → data/news.json)
|
||||||
build-slugs.js # routes.json для prerender
|
├── install-cron.sh (установка cron на web.hhivp.com)
|
||||||
prerender.js # SPA → статичные HTML по маршрутам через puppeteer
|
└── package.json (изолированный fast-xml-parser)
|
||||||
pull-external-rss.js # cron: внешние RSS → data/news.json (агрегатор)
|
|
||||||
public/uploads/ # картинки, перенесены из WP /wp-content/uploads/
|
Dockerfile (multi-stage: node:22-alpine build → nginx:1.29-alpine serve)
|
||||||
nginx/ # vhost для прода (симлинк из /etc/nginx/conf.d/)
|
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 страницы** скрейпом из WP DB `pushkinohistory_ru` на `db.hhivp.com`
|
||||||
- 7 постов + 4 страницы
|
- **URL-encoded кириллические slug'и WP** → транслитерированы (`/segodnya-nochyu-rossiyane-uvidyat-pervoe/`); старые URL → 301 через nginx map по `$uri`
|
||||||
- 6 картинок в `public/uploads/`
|
- **WP-resized URL** (`-1024x768.png`) → оригинал автоматически в `convert_posts.py:RESIZED_RE`
|
||||||
- URL-encoded кириллические slugs WP → транслитерированы (`/segodnya-nochyu-rossiyane-uvidyat-pervoe/`); старые URL → 301 через `nginx/map`
|
- **Frontmatter-флаги для иерархии главной:**
|
||||||
|
- `featured: true` + `featuredImage` — пин на верх как «Главная история» (Воронино, Старое Село)
|
||||||
|
- `hideFromList: true` — скрыть с главной (3 «технические работы»), доступ только через рубрику
|
||||||
|
- **Категория `main`** — псевдо-флаг «попадает на главную»; не показывается в плашках и в сайдбаре
|
||||||
|
|
||||||
## RSS
|
## RSS
|
||||||
|
|
||||||
- **Свой `/feed/`** — IPB-совместимый RSS 2.0 с полным HTML в `<content:encoded>`, стабильными `<guid isPermaLink>`, `<dc:creator>`, категориями. Для импорта в `forum.pushkinohistory.ru`.
|
### Свой `/feed.xml` (для IPB Importer)
|
||||||
- **Внешние фиды** — `src/content/feeds.json` (список URL), парсятся cron-скриптом `scripts/pull-external-rss.js` → `data/news.json` (bind-mount), фронт читает client-side через `/api/news.json`. Каждое добавление источника = редактирование `feeds.json` + push.
|
|
||||||
|
- Полный 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
|
```bash
|
||||||
# Локально:
|
|
||||||
cd E:\Projects\pushkinohistory-ru-v2
|
cd E:\Projects\pushkinohistory-ru-v2
|
||||||
git add . && git commit -m "..." && git push
|
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)
|
301-редиректы со старых WP-URL — через `map $uri $legacy_redirect` (см. файл). Если нужно добавить новый редирект — отредактировать map и pushнуть.
|
||||||
- Порт: `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` в этом репо
|
|
||||||
|
|
||||||
## Откат на WP v1
|
## Откат на WP v1
|
||||||
|
|
||||||
Старый WP в `/opt/docker/sites/pushkinohistory-ru/` (контейнер `pushkinohistory-ru:4143`) сохранён. Чтобы откатиться:
|
Старый WP-контейнер `pushkinohistory-ru:4143` + БД `pushkinohistory_ru` на `db.hhivp.com` сохранены. Откат за ~1 минуту:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh striker@web.hhivp.com
|
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
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
```
|
```
|
||||||
|
|
||||||
## БД (WP, v1)
|
После 1-2 недель стабильной работы v2 — старый WP можно удалить (контейнер + БД + репо).
|
||||||
|
|
||||||
DB на `db.hhivp.com` (45.10.53.205), `pushkinohistory_ru`/`u_pushhist`, prefix `ph_`. **Не удалять** — нужна для отката и как источник для повторного скрейпа.
|
|
||||||
|
|
||||||
## Форум
|
## Форум
|
||||||
|
|
||||||
`forum.pushkinohistory.ru` — IPB 4.x в отдельном контейнере `forum-pushkinohistory-ru:4144`. **v2 редизайном не затронут.**
|
`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:` — уникальное, 120–160 символов. **Не оставлять пустым** — пустая строка не падает на дефолт `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-08:** v1 (WordPress 6.x) контейнеризован, миграция со str-u-01 на web.hhivp.com
|
||||||
- 2026-05-14: фикс trust-proxy.conf для Docker bridges (Better WP Security)
|
- **2026-05-14:** фикс trust-proxy.conf для Docker bridges (Better WP Security)
|
||||||
- 2026-05-21: v2 редизайн — Vite+React+Tailwind, отказ от WP, RSS-агрегатор внешних фидов + свой RSS для IPB
|
- **2026-05-21:** v2 редизайн — Vite+React → Astro 5 (стек как у `anotherreflections-website-v2`). Cutover, бэкап старого WP в репо `pushkinohistory-ru` + БД на `db.hhivp.com` (~2 недели на наблюдение).
|
||||||
|
- **2026-05-30:** заполнены descriptions всем 11 страницам/постам (были пустыми → дубли в Я.Вебмастере).
|
||||||
|
|||||||
41
Dockerfile
41
Dockerfile
@@ -1,40 +1,27 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
# ─── Stage 1: build SPA + prerender via puppeteer ──────────────────────────
|
# ─── Stage 1: build static site (Astro SSG) ────────────────────────────────
|
||||||
FROM node:22-alpine AS builder
|
FROM node:22-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
SHELL ["/bin/sh", "-o", "pipefail", "-c"]
|
||||||
|
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm install --no-audit --no-fund
|
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 . .
|
COPY . .
|
||||||
RUN npm run build:prerender
|
RUN npm run build
|
||||||
|
|
||||||
# ─── Stage 2: runtime (Express) ─────────────────────────────────────────────
|
# ─── Stage 2: nginx runtime ─────────────────────────────────────────────────
|
||||||
FROM node:22-alpine
|
FROM nginx:1.29-alpine
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
ENV NODE_ENV=production \
|
RUN rm -rf /usr/share/nginx/html/*
|
||||||
PORT=3000
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
COPY package.json package-lock.json* ./
|
# Каталог для bind-mounted news.json от хостового cron-агрегатора
|
||||||
RUN npm install --omit=dev --no-audit --no-fund && npm cache clean --force
|
RUN mkdir -p /var/lib/pushkino/data
|
||||||
|
|
||||||
COPY --from=builder /app/dist ./dist
|
EXPOSE 80
|
||||||
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
|
|
||||||
|
|
||||||
RUN mkdir -p /app/public/uploads /app/data && chown -R node:node /app/public /app/data
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||||
|
CMD wget -q --spider http://127.0.0.1/ || exit 1
|
||||||
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"]
|
|
||||||
|
|||||||
54
README.md
Normal file
54
README.md
Normal 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
45
SECURITY.md
Normal 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
16
astro.config.mjs
Normal 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/'),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -6,21 +6,20 @@ services:
|
|||||||
container_name: pushkinohistory-ru-v2
|
container_name: pushkinohistory-ru-v2
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:4146:3000"
|
- "127.0.0.1:4146:80"
|
||||||
environment:
|
|
||||||
NODE_ENV: production
|
|
||||||
PORT: 3000
|
|
||||||
volumes:
|
volumes:
|
||||||
- /opt/www/pushkinohistory.ru/uploads:/app/public/uploads:ro
|
# cron на хосте пишет news.json для агрегатора внешних RSS
|
||||||
- /opt/docker/sites/pushkinohistory-ru-v2/data:/app/data:ro
|
- /opt/docker/sites/pushkinohistory-ru-v2/data:/var/lib/pushkino/data:ro
|
||||||
cap_drop: [ALL]
|
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]
|
security_opt: [no-new-privileges:true]
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /tmp:noexec,nosuid,size=16m
|
- /tmp:noexec,nosuid,size=16m
|
||||||
|
- /var/cache/nginx:size=32m
|
||||||
|
- /var/run:size=4m
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 15s
|
start_period: 10s
|
||||||
|
|||||||
15
index.html
15
index.html
@@ -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
91
nginx.conf
Normal 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; }
|
||||||
|
}
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
# pushkinohistory.ru — Vite+React (v2)
|
# pushkinohistory.ru — Astro v2
|
||||||
# Container: pushkinohistory-ru-v2 on 127.0.0.1:4146
|
# 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)
|
# v2 cutover: 2026-05-21 (старый WP на :4143 оставлен в /opt/docker/sites/pushkinohistory-ru как backup для отката)
|
||||||
|
|
||||||
# 301-редиректы со старых URL-encoded WP slugs (cyrillic) на новые транслитерированные.
|
# 301-редиректы со старых URL-encoded WP-cyrillic slugs на новые транслитерированные.
|
||||||
# nginx уже декодирует URI до cyrillic'а, поэтому в ключах map'а — кириллица в UTF-8.
|
# $uri — URL-декодированный путь (UTF-8 cyrillic). $request_uri сохраняет percent-encoding,
|
||||||
map $request_uri $legacy_redirect {
|
# поэтому для cyrillic-slugs нужен именно $uri.
|
||||||
|
map $uri $legacy_redirect {
|
||||||
default "";
|
default "";
|
||||||
~^/добро-пожаловать/?$ /dobro-pozhalovat/;
|
~^/добро-пожаловать/?$ /dobro-pozhalovat/;
|
||||||
~^/фото/?$ /foto/;
|
~^/фото/?$ /foto/;
|
||||||
~^/сегодня-ночью-россияне-увидят-первое-суперлуние-года-волчью-луну/?$ /segodnya-nochyu-rossiyane-uvidyat-pervoe/;
|
~^/сегодня-ночью-россияне-увидят-первое-суперлуние-года-волчью-луну/?$ /segodnya-nochyu-rossiyane-uvidyat-pervoe/;
|
||||||
~^/первые-20-градусные-морозы/?$ /pervye-20-gradusnye-morozy/;
|
~^/первые-20-градусные-морозы/?$ /pervye-20-gradusnye-morozy/;
|
||||||
|
# WP feed → новый Astro feed
|
||||||
|
~^/feed/?$ /feed.xml;
|
||||||
|
~^/feed/rss2/?$ /feed.xml;
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
@@ -59,39 +63,18 @@ server {
|
|||||||
|
|
||||||
client_max_body_size 4M;
|
client_max_body_size 4M;
|
||||||
|
|
||||||
# 301-редиректы с легаси WP-slugs на новые транслитерированные пути
|
# 301-редиректы с легаси WP-URL
|
||||||
if ($legacy_redirect != "") {
|
if ($legacy_redirect != "") {
|
||||||
return 301 $legacy_redirect;
|
return 301 $legacy_redirect;
|
||||||
}
|
}
|
||||||
|
|
||||||
# WP-эндпоинты — больше не существуют, отдаём 410 Gone (помогает поисковикам пометить как удалённые)
|
# WP-эндпоинты больше не существуют — 410 Gone (поисковики помечают как удалённые)
|
||||||
location ~* ^/(wp-admin|wp-login\.php|wp-content|wp-includes|xmlrpc\.php|wp-cron\.php|wp-config\.php|readme\.html)$ {
|
location ~* ^/(wp-admin|wp-login\.php|wp-content|wp-includes|xmlrpc\.php|wp-cron\.php|wp-config\.php|readme\.html) {
|
||||||
return 410;
|
return 410;
|
||||||
}
|
}
|
||||||
|
|
||||||
# RSS-фид (статичный файл, отдаётся из dist)
|
# Всё остальное — на контейнер v2. Astro nginx внутри сам разруливает кэши,
|
||||||
location = /feed/ {
|
# MIME для RSS, /api/news.json из bind-mount и т.д.
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:4146;
|
proxy_pass http://127.0.0.1:4146;
|
||||||
include /etc/nginx/templates/proxy.conf;
|
include /etc/nginx/templates/proxy.conf;
|
||||||
|
|||||||
7009
package-lock.json
generated
7009
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
39
package.json
39
package.json
@@ -1,33 +1,28 @@
|
|||||||
{
|
{
|
||||||
"name": "pushkinohistory-ru-v2",
|
"name": "pushkinohistory-ru-v2",
|
||||||
"version": "0.1.0",
|
|
||||||
"description": "История города Пушкино — статический сайт + RSS",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"private": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "astro dev",
|
||||||
"build": "vite build && node scripts/build-slugs.js && node scripts/build-sitemap.js && node scripts/build-rss.js",
|
"build": "astro build",
|
||||||
"build:prerender": "vite build && node scripts/build-slugs.js && node scripts/build-sitemap.js && node scripts/build-rss.js && node scripts/prerender.js",
|
"preview": "astro preview",
|
||||||
"prerender": "node scripts/prerender.js",
|
"astro": "astro",
|
||||||
"preview": "vite preview",
|
"indexnow": "node scripts/indexnow.js"
|
||||||
"start": "node server/index.js",
|
|
||||||
"pull-rss": "node scripts/pull-external-rss.js"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/pt-serif": "^5.2.5",
|
"@astrojs/rss": "^4.0.12",
|
||||||
|
"@astrojs/sitemap": "^3.6.0",
|
||||||
"@fontsource/ibm-plex-sans": "^5.2.5",
|
"@fontsource/ibm-plex-sans": "^5.2.5",
|
||||||
"compression": "^1.7.5",
|
"@fontsource/pt-serif": "^5.2.5",
|
||||||
"express": "^4.21.2",
|
"astro": "^6.3.6",
|
||||||
"fast-xml-parser": "^4.5.0",
|
"sanitize-html": "^2.17.0"
|
||||||
"react": "^18.3.1",
|
|
||||||
"react-dom": "^18.3.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@types/sanitize-html": "^2.16.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"sharp": "^0.34.5"
|
||||||
"postcss": "^8.4.49",
|
|
||||||
"puppeteer": "^23.10.4",
|
|
||||||
"tailwindcss": "^3.4.17",
|
|
||||||
"vite": "^6.0.5"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
6
public/.well-known/security.txt
Normal file
6
public/.well-known/security.txt
Normal 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/
|
||||||
1
public/9018cf11050b4f379b8cec01ae3239bb.txt
Normal file
1
public/9018cf11050b4f379b8cec01ae3239bb.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
9018cf11050b4f379b8cec01ae3239bb
|
||||||
12
public/ai.txt
Normal file
12
public/ai.txt
Normal 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
5
public/favicon.svg
Normal 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
7
public/hhivp-mark.svg
Normal 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
10
public/humans.txt
Normal 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
30
public/llms.txt
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# История города Пушкино
|
||||||
|
|
||||||
|
> Краеведческий сайт, посвящённый истории города Пушкино (Московская область).
|
||||||
|
> Материалы 2010–2026 годов: исторические статьи, фотографии, новости.
|
||||||
|
> Связан с форумом 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
26
public/robots.txt
Normal 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
|
||||||
@@ -34,9 +34,13 @@ def slugify_ru(s: str) -> str:
|
|||||||
return res or 'untitled'
|
return res or 'untitled'
|
||||||
|
|
||||||
UPLOAD_RE = re.compile(r'https?://(?:www\.)?pushkinohistory\.ru/wp-content/uploads/[^/]+/[^/]+/([^"\'\s)]+)')
|
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:
|
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 = {
|
CATEGORIES = {
|
||||||
20: [], 23: [], 73: [], 94: [], # pages — no category
|
20: [], 23: [], 73: [], 94: [], # pages — no category
|
||||||
|
|||||||
90
scripts/convert_to_markdown.py
Normal file
90
scripts/convert_to_markdown.py
Normal 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
120
scripts/indexnow.js
Normal 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
42
scripts/install-cron.sh
Normal 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
9
scripts/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
124
scripts/pull-external-rss.mjs
Normal file
124
scripts/pull-external-rss.mjs
Normal 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);
|
||||||
|
});
|
||||||
@@ -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}`);
|
|
||||||
});
|
|
||||||
133
src/App.jsx
133
src/App.jsx
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
26
src/components/Analytics.astro
Normal file
26
src/components/Analytics.astro
Normal 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>
|
||||||
|
)}
|
||||||
101
src/components/CookieConsent.astro
Normal file
101
src/components/CookieConsent.astro
Normal 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>
|
||||||
82
src/components/Footer.astro
Normal file
82
src/components/Footer.astro
Normal 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">Сделано в 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>
|
||||||
@@ -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
124
src/components/Header.astro
Normal 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>
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
169
src/components/PostCard.astro
Normal file
169
src/components/PostCard.astro
Normal 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>
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
92
src/components/Sidebar.astro
Normal file
92
src/components/Sidebar.astro
Normal 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} {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>
|
||||||
@@ -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
52
src/consts.ts
Normal 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
37
src/content.config.ts
Normal 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 };
|
||||||
@@ -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
22
src/content/pages/dobro-pozhalovat.md
Normal file
22
src/content/pages/dobro-pozhalovat.md
Normal 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>
|
||||||
|
|
||||||
|
|
||||||
15
src/content/pages/forum.md
Normal file
15
src/content/pages/forum.md
Normal 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
32
src/content/pages/foto.md
Normal 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>
|
||||||
109
src/content/pages/history.md
Normal file
109
src/content/pages/history.md
Normal 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>
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
"title": "Сегодня ночью россияне увидят первое суперлуние года - Волчью луну",
|
"title": "Сегодня ночью россияне увидят первое суперлуние года - Волчью луну",
|
||||||
"date": "2026-01-04 04:19:20",
|
"date": "2026-01-04 04:19:20",
|
||||||
"excerpt": "",
|
"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": [
|
"categories": [
|
||||||
"Главная"
|
"Главная"
|
||||||
],
|
],
|
||||||
|
|||||||
27
src/content/posts/c-nastupayushhim-novym-2014-godom.md
Normal file
27
src/content/posts/c-nastupayushhim-novym-2014-godom.md
Normal 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
|
||||||
|
---
|
||||||
|
|
||||||
|
От лица администрации и от себя лично поздравляю всех с Новым годом! Желаю всем счастья, здоровья, сбытия всех Ваших мечт, финансового и душевного спокойствия, искорки в глазах и успехов в выполнении всех Ваших начинаний.Постарайтесь забыть все то плохое, что у Вас может быть случилось, помните, что свою жизнь Вы делаете и сами, а значит нужно стремиться к самому лучшему! Еще раз всех с праздником, до встречи уже в Новом году!)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
admin & pushkinohistory.ru
|
||||||
17
src/content/posts/pervye-20-gradusnye-morozy.md
Normal file
17
src/content/posts/pervye-20-gradusnye-morozy.md
Normal 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 -->
|
||||||
@@ -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 -->
|
||||||
28
src/content/posts/staroe-staroe-selo.md
Normal file
28
src/content/posts/staroe-staroe-selo.md
Normal 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
|
||||||
19
src/content/posts/vnimanie-texnicheskie-raboty-2.md
Normal file
19
src/content/posts/vnimanie-texnicheskie-raboty-2.md
Normal 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 минут.
|
||||||
|
|
||||||
|
Приносим Вам свои извинения за доставленные неудобства и надеемся на Ваше понимание!
|
||||||
19
src/content/posts/vnimanie-texnicheskie-raboty.md
Normal file
19
src/content/posts/vnimanie-texnicheskie-raboty.md
Normal 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 минут.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Приносим свои извинения за доставленные неудобства.
|
||||||
56
src/content/posts/voronino.md
Normal file
56
src/content/posts/voronino.md
Normal 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>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<em>*Энколпионы – небольшие коробочки или кресты с мощами святого, предназначенные для ношения на груди. </em>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<strong>Василий Коршун</strong>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Опубликовано: "Пушкинский Вестник" 2005 г. №11(268)
|
||||||
|
|
||||||
|
Фото (вверху) передано автору статьи С.В. Демидовым.
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
137
src/layouts/BaseLayout.astro
Normal file
137
src/layouts/BaseLayout.astro
Normal 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
35
src/lib/extract.ts
Normal 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(/ /g, ' ')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/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
120
src/lib/rss-helpers.ts
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Завернуть в 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(/ /g, ' ')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
12
src/main.jsx
12
src/main.jsx
@@ -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
23
src/pages/404.astro
Normal 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>
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
95
src/pages/[slug].astro
Normal 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>
|
||||||
49
src/pages/cat/[slug].astro
Normal file
49
src/pages/cat/[slug].astro
Normal 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} {plural(posts.length, ['запись', 'записи', 'записей'])}
|
||||||
|
·
|
||||||
|
<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>
|
||||||
58
src/pages/cat/[slug]/feed.xml.ts
Normal file
58
src/pages/cat/[slug]/feed.xml.ts
Normal 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
44
src/pages/feed.xml.ts
Normal 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
59
src/pages/index.astro
Normal 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
94
src/pages/news.astro
Normal 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
57
src/pages/privacy.astro
Normal 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
29
src/pages/sitemap.txt.ts
Normal 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
155
src/styles/global.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
3
tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict"
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user