rewrite: Vite+React → Astro 5 + Content Collections
Some checks failed
deploy / deploy (push) Failing after 12s
Some checks failed
deploy / deploy (push) Failing after 12s
- Бэкап старой версии на ветке vite-react-backup - Stack: Astro 5 + nginx:alpine runtime, образ ~50 МБ (был ~600 МБ) - @astrojs/rss заменён ручным buildRss() — гарантия CDATA в content:encoded для IPB Importer - @astrojs/sitemap → sitemap-index.xml + sitemap.txt - 152-ФЗ cookie consent + privacy.astro + Analytics с gating - AI-файлы: robots.txt с явным allow для AI-краулеров, ai.txt, llms.txt - Гибридный визуал: фото-фон шапки (аэрофото Пушкино) + PT Serif + IBM Plex Sans - Иерархия: hero "Главная история" с рамкой + "Ещё из истории" + "Хроника" - Категория "main" (псевдо) скрыта из плашек и из Рубрик в сайдбаре - hideFromList: true для технических постов - featuredImage в frontmatter для постов без хорошей первой <img> - WP resized-URL (-WxH.ext) автоматически → оригинал - CI/CD: .gitea/workflows/deploy.yml (push → SSH-build) - Внешние RSS: scripts/pull-external-rss.mjs пишет news.json в bind-mount, фронт фетчит client-side
This commit is contained in:
120
src/lib/rss-helpers.ts
Normal file
120
src/lib/rss-helpers.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import { SITE_URL } from '../consts';
|
||||
|
||||
/** Sanitize и нормализация HTML тела поста для RSS `<content:encoded>`. */
|
||||
export function sanitizeForRss(html: string): string {
|
||||
const absolutized = html.replace(/(src|href)=["']\/uploads\//g, `$1="${SITE_URL}/uploads/`);
|
||||
return sanitizeHtml(absolutized, {
|
||||
allowedTags: [
|
||||
'p', 'br', 'strong', 'em', 'b', 'i', 'u', 's',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'a', 'img',
|
||||
'ul', 'ol', 'li',
|
||||
'blockquote', 'pre', 'code',
|
||||
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
||||
'div', 'span', 'hr',
|
||||
],
|
||||
allowedAttributes: {
|
||||
a: ['href', 'title', 'rel', 'target'],
|
||||
img: ['src', 'alt', 'title', 'width', 'height'],
|
||||
div: ['align'],
|
||||
span: ['align'],
|
||||
p: ['align'],
|
||||
td: ['colspan', 'rowspan'],
|
||||
th: ['colspan', 'rowspan'],
|
||||
},
|
||||
transformTags: {
|
||||
a: (tagName, attribs) => ({
|
||||
tagName,
|
||||
attribs: {
|
||||
...attribs,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
}),
|
||||
},
|
||||
allowedSchemes: ['http', 'https', 'mailto'],
|
||||
});
|
||||
}
|
||||
|
||||
/** Экранировать строку для XML-атрибутов и текстовых нод. */
|
||||
export function xmlEscape(s: string): string {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** Завернуть в CDATA, нейтрализовав `]]>` внутри. */
|
||||
export function cdata(s: string): string {
|
||||
return `<![CDATA[${String(s).replace(/]]>/g, ']]]]><![CDATA[>')}]]>`;
|
||||
}
|
||||
|
||||
/** Текстовое описание из HTML (для `<description>`). */
|
||||
export function plainTextExcerpt(html: string, max = 500): string {
|
||||
const txt = html
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (txt.length <= max) return txt;
|
||||
return txt.slice(0, max).replace(/\s+\S*$/, '') + '…';
|
||||
}
|
||||
|
||||
/** Построить полный RSS 2.0 XML с CDATA-завёрнутым content:encoded. */
|
||||
export function buildRss(opts: {
|
||||
title: string;
|
||||
description: string;
|
||||
selfUrl: string;
|
||||
language: string;
|
||||
items: Array<{
|
||||
title: string;
|
||||
link: string;
|
||||
pubDate: Date;
|
||||
description: string;
|
||||
contentHtml: string;
|
||||
author: string;
|
||||
categories: string[];
|
||||
}>;
|
||||
}): string {
|
||||
const { title, description, selfUrl, language, items } = opts;
|
||||
const lastBuild = new Date().toUTCString();
|
||||
const itemsXml = items
|
||||
.map((it) => ` <item>
|
||||
<title>${cdata(it.title)}</title>
|
||||
<link>${xmlEscape(it.link)}</link>
|
||||
<guid isPermaLink="true">${xmlEscape(it.link)}</guid>
|
||||
<pubDate>${it.pubDate.toUTCString()}</pubDate>
|
||||
<dc:creator>${cdata(it.author)}</dc:creator>
|
||||
${it.categories.map((c) => ` <category>${cdata(c)}</category>`).join('\n')}
|
||||
<description>${cdata(it.description)}</description>
|
||||
<content:encoded>${cdata(it.contentHtml)}</content:encoded>
|
||||
</item>`)
|
||||
.join('\n');
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"
|
||||
xmlns:content="http://purl.org/rss/1.0/modules/content/"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>${cdata(title)}</title>
|
||||
<link>${xmlEscape(SITE_URL + '/')}</link>
|
||||
<atom:link href="${xmlEscape(selfUrl)}" rel="self" type="application/rss+xml" />
|
||||
<description>${cdata(description)}</description>
|
||||
<language>${language}</language>
|
||||
<lastBuildDate>${lastBuild}</lastBuildDate>
|
||||
<ttl>60</ttl>
|
||||
${itemsXml}
|
||||
</channel>
|
||||
</rss>
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user