rewrite: Vite+React → Astro 5 + Content Collections
Some checks failed
deploy / deploy (push) Failing after 12s
Some checks failed
deploy / deploy (push) Failing after 12s
- Бэкап старой версии на ветке vite-react-backup - Stack: Astro 5 + nginx:alpine runtime, образ ~50 МБ (был ~600 МБ) - @astrojs/rss заменён ручным buildRss() — гарантия CDATA в content:encoded для IPB Importer - @astrojs/sitemap → sitemap-index.xml + sitemap.txt - 152-ФЗ cookie consent + privacy.astro + Analytics с gating - AI-файлы: robots.txt с явным allow для AI-краулеров, ai.txt, llms.txt - Гибридный визуал: фото-фон шапки (аэрофото Пушкино) + PT Serif + IBM Plex Sans - Иерархия: hero "Главная история" с рамкой + "Ещё из истории" + "Хроника" - Категория "main" (псевдо) скрыта из плашек и из Рубрик в сайдбаре - hideFromList: true для технических постов - featuredImage в frontmatter для постов без хорошей первой <img> - WP resized-URL (-WxH.ext) автоматически → оригинал - CI/CD: .gitea/workflows/deploy.yml (push → SSH-build) - Внешние RSS: scripts/pull-external-rss.mjs пишет news.json в bind-mount, фронт фетчит client-side
This commit is contained in:
54
.gitea/workflows/deploy.yml
Normal file
54
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,54 @@
|
||||
name: deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- 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 to web.hhivp.com
|
||||
run: |
|
||||
ssh -i ~/.ssh/id_deploy striker@web.hhivp.com bash -s <<'REMOTE'
|
||||
set -euo pipefail
|
||||
REPO_URL="https://${GITEA_USER}:${GITEA_TOKEN}@git.striker.su/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
|
||||
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
|
||||
env:
|
||||
GITEA_USER: striker
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
16
.gitignore
vendored
16
.gitignore
vendored
@@ -1,10 +1,22 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.astro/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.vite/
|
||||
data/news.json
|
||||
|
||||
# generated content (исходник в БД и/или в посты-md)
|
||||
scripts/posts-raw.jsonl
|
||||
|
||||
# cron-агрегатор внешних RSS (данные пишутся в раннее)
|
||||
data/news.json
|
||||
|
||||
# screenshot debug-помойка
|
||||
poc-*.jpeg
|
||||
poc-*.png
|
||||
debug-*.jpeg
|
||||
debug-*.png
|
||||
|
||||
# Vite-React backup (доступен через ветку vite-react-backup на origin)
|
||||
|
||||
8
.vite/deps/_metadata.json
Normal file
8
.vite/deps/_metadata.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"hash": "012524d0",
|
||||
"configHash": "31a793a1",
|
||||
"lockfileHash": "e3b0c442",
|
||||
"browserHash": "64dd85ba",
|
||||
"optimized": {},
|
||||
"chunks": {}
|
||||
}
|
||||
3
.vite/deps/package.json
Normal file
3
.vite/deps/package.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
40
Dockerfile
40
Dockerfile
@@ -1,40 +1,26 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ─── Stage 1: build SPA + prerender via puppeteer ──────────────────────────
|
||||
FROM node:22-alpine AS builder
|
||||
# ─── Stage 1: build static site (Astro SSG) ────────────────────────────────
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --no-audit --no-fund
|
||||
|
||||
RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont
|
||||
ENV PUPPETEER_SKIP_DOWNLOAD=true \
|
||||
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||
|
||||
COPY . .
|
||||
RUN npm run build:prerender
|
||||
RUN npm run build
|
||||
|
||||
# ─── Stage 2: runtime (Express) ─────────────────────────────────────────────
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
# ─── Stage 2: nginx runtime ─────────────────────────────────────────────────
|
||||
FROM nginx:1.29-alpine
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000
|
||||
RUN rm -rf /usr/share/nginx/html/*
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --omit=dev --no-audit --no-fund && npm cache clean --force
|
||||
# Каталог для bind-mounted news.json от хостового cron-агрегатора
|
||||
RUN mkdir -p /var/lib/pushkino/data
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/server ./server
|
||||
COPY --from=builder /app/scripts/pull-external-rss.js ./scripts/pull-external-rss.js
|
||||
COPY --from=builder /app/src/content/feeds.json ./src/content/feeds.json
|
||||
EXPOSE 80
|
||||
|
||||
RUN mkdir -p /app/public/uploads /app/data && chown -R node:node /app/public /app/data
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD wget --quiet --tries=1 --spider http://127.0.0.1:3000/api/health || exit 1
|
||||
|
||||
USER node
|
||||
CMD ["node", "server/index.js"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD wget -q --spider http://127.0.0.1/ || exit 1
|
||||
|
||||
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
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:4146:3000"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
- "127.0.0.1:4146:80"
|
||||
volumes:
|
||||
- /opt/www/pushkinohistory.ru/uploads:/app/public/uploads:ro
|
||||
- /opt/docker/sites/pushkinohistory-ru-v2/data:/app/data:ro
|
||||
# cron на хосте пишет news.json для агрегатора внешних RSS
|
||||
- /opt/docker/sites/pushkinohistory-ru-v2/data:/var/lib/pushkino/data:ro
|
||||
cap_drop: [ALL]
|
||||
cap_add: [NET_BIND_SERVICE, CHOWN, SETUID, SETGID]
|
||||
cap_add: [NET_BIND_SERVICE, CHOWN, SETUID, SETGID, DAC_OVERRIDE]
|
||||
security_opt: [no-new-privileges:true]
|
||||
tmpfs:
|
||||
- /tmp:noexec,nosuid,size=16m
|
||||
- /var/cache/nginx:size=32m
|
||||
- /var/run:size=4m
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:3000/api/health"]
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
start_period: 10s
|
||||
|
||||
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; }
|
||||
}
|
||||
6980
package-lock.json
generated
6980
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",
|
||||
"version": "0.1.0",
|
||||
"description": "История города Пушкино — статический сайт + RSS",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build && node scripts/build-slugs.js && node scripts/build-sitemap.js && node scripts/build-rss.js",
|
||||
"build:prerender": "vite build && node scripts/build-slugs.js && node scripts/build-sitemap.js && node scripts/build-rss.js && node scripts/prerender.js",
|
||||
"prerender": "node scripts/prerender.js",
|
||||
"preview": "vite preview",
|
||||
"start": "node server/index.js",
|
||||
"pull-rss": "node scripts/pull-external-rss.js"
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/pt-serif": "^5.2.5",
|
||||
"@astrojs/rss": "^4.0.12",
|
||||
"@astrojs/sitemap": "^3.6.0",
|
||||
"@fontsource/ibm-plex-sans": "^5.2.5",
|
||||
"compression": "^1.7.5",
|
||||
"express": "^4.21.2",
|
||||
"fast-xml-parser": "^4.5.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"@fontsource/pt-serif": "^5.2.5",
|
||||
"astro": "^6.3.6",
|
||||
"sanitize-html": "^2.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"puppeteer": "^23.10.4",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^6.0.5"
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"fast-xml-parser": "^4.5.0",
|
||||
"sharp": "^0.34.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
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 |
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
|
||||
20
public/robots.txt
Normal file
20
public/robots.txt
Normal file
@@ -0,0 +1,20 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
# AI-crawlers
|
||||
User-agent: GPTBot
|
||||
Allow: /
|
||||
User-agent: ClaudeBot
|
||||
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'
|
||||
|
||||
UPLOAD_RE = re.compile(r'https?://(?:www\.)?pushkinohistory\.ru/wp-content/uploads/[^/]+/[^/]+/([^"\'\s)]+)')
|
||||
# WP-resized варианты: file-1024x768.png → file.png. У нас в /uploads/ лежит только оригинал.
|
||||
RESIZED_RE = re.compile(r'(/uploads/[^"\'\s)]+?)-\d+x\d+(\.\w+)')
|
||||
|
||||
def rewrite_uploads(html: str) -> str:
|
||||
return UPLOAD_RE.sub(r'/uploads/\1', html)
|
||||
html = UPLOAD_RE.sub(r'/uploads/\1', html)
|
||||
html = RESIZED_RE.sub(r'\1\2', html)
|
||||
return html
|
||||
|
||||
CATEGORIES = {
|
||||
20: [], 23: [], 73: [], 94: [], # pages — no category
|
||||
|
||||
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/')
|
||||
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>
|
||||
36
src/components/Footer.astro
Normal file
36
src/components/Footer.astro
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
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>
|
||||
</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); }
|
||||
</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
16
src/content/pages/dobro-pozhalovat.md
Normal file
16
src/content/pages/dobro-pozhalovat.md
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
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>
|
||||
12
src/content/pages/forum.md
Normal file
12
src/content/pages/forum.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
title: "Форум"
|
||||
slug: forum
|
||||
legacyId: 94
|
||||
pubDate: 2010-04-02T11:00:52+03:00
|
||||
description: ""
|
||||
---
|
||||
|
||||
<p><meta http-equiv="Refresh" content="0;url=http://forum.pushkinohistory.ru"><br />
|
||||
|
||||
<center>перенаправление на форум....<br />
|
||||
|
||||
21
src/content/pages/foto.md
Normal file
21
src/content/pages/foto.md
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
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>
|
||||
|
||||
59
src/content/pages/history.md
Normal file
59
src/content/pages/history.md
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: "История"
|
||||
slug: history
|
||||
legacyId: 20
|
||||
pubDate: 2010-02-22T18:46:33+03:00
|
||||
description: ""
|
||||
---
|
||||
|
||||
<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 года город Пушкино стал районным центром. В состав района вошли два рабочих посёлка — Ивантеевка и Красноармейск, дачный посёлок Мамонтовка; Софринская, Путиловская, Пушкинская волости; несколько селений Щелковской и Хотьковской волостей. В том же году из Москвы в Пушкино прошла первая электричка. Через год электропоезда шли уже до станции Правда.
|
||||
@@ -21,7 +21,7 @@
|
||||
"title": "Сегодня ночью россияне увидят первое суперлуние года - Волчью луну",
|
||||
"date": "2026-01-04 04:19:20",
|
||||
"excerpt": "",
|
||||
"html": "<!-- wp:paragraph -->\n<p></p>\n<!-- /wp:paragraph -->\n\n<!-- wp:image {\"id\":227,\"sizeSlug\":\"large\",\"linkDestination\":\"media\",\"className\":\"is-style-default\"} -->\n<figure class=\"wp-block-image size-large is-style-default\"><a href=\"/uploads/image.png\"><img src=\"/uploads/image-1024x581.png\" alt=\"\" class=\"wp-image-227\"/></a></figure>\n<!-- /wp:image -->\n\n<!-- wp:paragraph -->\n<p>«В ночь с 3 на 4 января россияне смогут увидеть яркое астрономическое событие — суперполнолуние. Полная Луна приблизится к Земле на максимально близкое расстояние, ее диск будет выглядеть на 15% ярче и почти на 8% больше среднего полнолуния.</p>\n<!-- /wp:paragraph -->\n\n<!-- wp:paragraph -->\n<p>Это явление январской суперлуны имеет фольклорное название — Волчья Луна, потому что в холодное время года волки чаще воют по ночам»</p>\n<!-- /wp:paragraph -->",
|
||||
"html": "<!-- wp:paragraph -->\n<p></p>\n<!-- /wp:paragraph -->\n\n<!-- wp:image {\"id\":227,\"sizeSlug\":\"large\",\"linkDestination\":\"media\",\"className\":\"is-style-default\"} -->\n<figure class=\"wp-block-image size-large is-style-default\"><a href=\"/uploads/image.png\"><img src=\"/uploads/image.png\" alt=\"\" class=\"wp-image-227\"/></a></figure>\n<!-- /wp:image -->\n\n<!-- wp:paragraph -->\n<p>«В ночь с 3 на 4 января россияне смогут увидеть яркое астрономическое событие — суперполнолуние. Полная Луна приблизится к Земле на максимально близкое расстояние, ее диск будет выглядеть на 15% ярче и почти на 8% больше среднего полнолуния.</p>\n<!-- /wp:paragraph -->\n\n<!-- wp:paragraph -->\n<p>Это явление январской суперлуны имеет фольклорное название — Волчья Луна, потому что в холодное время года волки чаще воют по ночам»</p>\n<!-- /wp:paragraph -->",
|
||||
"categories": [
|
||||
"Главная"
|
||||
],
|
||||
|
||||
23
src/content/posts/c-nastupayushhim-novym-2014-godom.md
Normal file
23
src/content/posts/c-nastupayushhim-novym-2014-godom.md
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
title: "C наступающим Новым 2014 Годом!"
|
||||
slug: c-nastupayushhim-novym-2014-godom
|
||||
legacyId: 145
|
||||
pubDate: 2013-12-31T19:29:10+03:00
|
||||
description: ""
|
||||
categories:
|
||||
- "Главная"
|
||||
- "Настоящее"
|
||||
- "Техническое"
|
||||
categorySlugs:
|
||||
- "main"
|
||||
- "today"
|
||||
- "tech"
|
||||
author: "История города Пушкино"
|
||||
hideFromList: true
|
||||
---
|
||||
|
||||
От лица администрации и от себя лично поздравляю всех с Новым годом! Желаю всем счастья, здоровья, сбытия всех Ваших мечт, финансового и душевного спокойствия, искорки в глазах и успехов в выполнении всех Ваших начинаний.Постарайтесь забыть все то плохое, что у Вас может быть случилось, помните, что свою жизнь Вы делаете и сами, а значит нужно стремиться к самому лучшему! Еще раз всех с праздником, до встречи уже в Новом году!)
|
||||
|
||||
|
||||
|
||||
|
||||
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: ""
|
||||
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: ""
|
||||
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 -->
|
||||
22
src/content/posts/staroe-staroe-selo.md
Normal file
22
src/content/posts/staroe-staroe-selo.md
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
title: "Старое, Старое Село"
|
||||
slug: staroe-staroe-selo
|
||||
legacyId: 137
|
||||
pubDate: 2012-05-16T18:28:48+03:00
|
||||
description: ""
|
||||
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>
|
||||
18
src/content/posts/vnimanie-texnicheskie-raboty-2.md
Normal file
18
src/content/posts/vnimanie-texnicheskie-raboty-2.md
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
title: "Внимание! Технические работы!"
|
||||
slug: vnimanie-texnicheskie-raboty-2
|
||||
legacyId: 158
|
||||
pubDate: 2015-05-29T01:33:35+03:00
|
||||
description: ""
|
||||
categories:
|
||||
- "Главная"
|
||||
- "Техническое"
|
||||
categorySlugs:
|
||||
- "main"
|
||||
- "tech"
|
||||
author: "История города Пушкино"
|
||||
hideFromList: true
|
||||
---
|
||||
|
||||
2 июня 2015г. с 3 до 4 утра, в связи с проведением технических работ на площадке оператора связи, обслуживающего нашу инфраструктуру, возможны перебои с предоставлением услуг длительностью до 30 минут.
|
||||
|
||||
17
src/content/posts/vnimanie-texnicheskie-raboty.md
Normal file
17
src/content/posts/vnimanie-texnicheskie-raboty.md
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
title: "Внимание! Технические работы!"
|
||||
slug: vnimanie-texnicheskie-raboty
|
||||
legacyId: 142
|
||||
pubDate: 2013-08-24T00:56:06+03:00
|
||||
description: ""
|
||||
categories:
|
||||
- "Главная"
|
||||
categorySlugs:
|
||||
- "main"
|
||||
author: "История города Пушкино"
|
||||
hideFromList: true
|
||||
---
|
||||
|
||||
В связи с проведением технических работ на серверах с 02:00 до 04:00 24 августа 2013 г. возможны перерывы в работе до 30 минут.
|
||||
|
||||
|
||||
36
src/content/posts/voronino.md
Normal file
36
src/content/posts/voronino.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: "Воронино"
|
||||
slug: voronino
|
||||
legacyId: 139
|
||||
pubDate: 2012-05-16T18:30:34+03:00
|
||||
description: ""
|
||||
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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
77
src/layouts/BaseLayout.astro
Normal file
77
src/layouts/BaseLayout.astro
Normal file
@@ -0,0 +1,77 @@
|
||||
---
|
||||
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();
|
||||
---
|
||||
|
||||
<!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" />
|
||||
|
||||
<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