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

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>
`;
}