rewrite: Vite+React → Astro 5 + Content Collections
Some checks failed
deploy / deploy (push) Failing after 12s

- Бэкап старой версии на ветке vite-react-backup
- Stack: Astro 5 + nginx:alpine runtime, образ ~50 МБ (был ~600 МБ)
- @astrojs/rss заменён ручным buildRss() — гарантия CDATA в content:encoded для IPB Importer
- @astrojs/sitemap → sitemap-index.xml + sitemap.txt
- 152-ФЗ cookie consent + privacy.astro + Analytics с gating
- AI-файлы: robots.txt с явным allow для AI-краулеров, ai.txt, llms.txt
- Гибридный визуал: фото-фон шапки (аэрофото Пушкино) + PT Serif + IBM Plex Sans
- Иерархия: hero "Главная история" с рамкой + "Ещё из истории" + "Хроника"
- Категория "main" (псевдо) скрыта из плашек и из Рубрик в сайдбаре
- hideFromList: true для технических постов
- featuredImage в frontmatter для постов без хорошей первой <img>
- WP resized-URL (-WxH.ext) автоматически → оригинал
- CI/CD: .gitea/workflows/deploy.yml (push → SSH-build)
- Внешние RSS: scripts/pull-external-rss.mjs пишет news.json в bind-mount, фронт фетчит client-side
This commit is contained in:
striker
2026-05-21 03:21:31 +03:00
parent a0219ee8f3
commit c65e07cd98
75 changed files with 5926 additions and 4142 deletions

View 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
View File

@@ -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)

View File

@@ -0,0 +1,8 @@
{
"hash": "012524d0",
"configHash": "31a793a1",
"lockfileHash": "e3b0c442",
"browserHash": "64dd85ba",
"optimized": {},
"chunks": {}
}

3
.vite/deps/package.json Normal file
View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

View File

@@ -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
View File

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

View File

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

View File

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

91
nginx.conf Normal file
View File

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

6980
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,33 +1,28 @@
{
"name": "pushkinohistory-ru-v2",
"version": "0.1.0",
"description": "История города Пушкино — статический сайт + RSS",
"private": true,
"type": "module",
"version": "0.2.0",
"private": true,
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "vite build && node scripts/build-slugs.js && node scripts/build-sitemap.js && node scripts/build-rss.js",
"build:prerender": "vite build && node scripts/build-slugs.js && node scripts/build-sitemap.js && node scripts/build-rss.js && node scripts/prerender.js",
"prerender": "node scripts/prerender.js",
"preview": "vite preview",
"start": "node server/index.js",
"pull-rss": "node scripts/pull-external-rss.js"
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"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"
}
}

View File

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

12
public/ai.txt Normal file
View File

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

5
public/favicon.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 271 B

30
public/llms.txt Normal file
View File

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

20
public/robots.txt Normal file
View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

52
src/consts.ts Normal file
View File

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

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

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View 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
View 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>

View 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 года город Пушкино стал районным центром. В состав района вошли два рабочих посёлка — Ивантеевка и Красноармейск, дачный посёлок Мамонтовка; Софринская, Путиловская, Пушкинская волости; несколько селений Щелковской и Хотьковской волостей. В том же году из Москвы в Пушкино прошла первая электричка. Через год электропоезда шли уже до станции Правда.

View File

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

View File

@@ -0,0 +1,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
---
От лица администрации и от себя лично поздравляю всех с Новым годом! Желаю всем счастья, здоровья, сбытия всех Ваших мечт, финансового и душевного спокойствия, искорки в глазах и успехов в выполнении всех Ваших начинаний.Постарайтесь забыть все то плохое, что у Вас может быть случилось, помните, что свою жизнь Вы делаете и сами, а значит нужно стремиться к самому лучшему! Еще раз всех с праздником, до встречи уже в Новом году!)
&nbsp;

View 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 -->

View File

@@ -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 -->

View 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>

View 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 минут.

View 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 минут.

View 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>

View File

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

View File

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

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

3
tsconfig.json Normal file
View File

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

View File

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