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

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' },
});
};