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
121 lines
3.8 KiB
TypeScript
121 lines
3.8 KiB
TypeScript
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>
|
||
`;
|
||
}
|