Files
sori.studio/server/utils/rss-feed.js

144 lines
4.5 KiB
JavaScript

import { createPostSummary } from '../../composables/createPostSummary.js'
import { getSiteSettings, listPosts } from '../repositories/content-repository'
const RSS_POST_LIMIT = 50
/**
* XML 텍스트 값을 이스케이프한다.
* @param {unknown} value - XML에 넣을 값
* @returns {string} 이스케이프된 텍스트
*/
const escapeXml = (value) => String(value || '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
/**
* 사이트 URL의 끝 슬래시를 제거한다.
* @param {unknown} value - 사이트 URL
* @returns {string} 정리된 사이트 URL
*/
const normalizeSiteUrl = (value) => String(value || '').trim().replace(/\/+$/, '')
/**
* URL을 절대 URL로 변환한다.
* @param {unknown} value - 원본 URL
* @param {string} siteUrl - 사이트 URL
* @returns {string} 절대 URL
*/
const toAbsoluteUrl = (value, siteUrl) => {
const url = String(value || '').trim()
if (!url) {
return ''
}
if (/^https?:\/\//i.test(url)) {
return url
}
if (url.startsWith('//')) {
return `https:${url}`
}
if (url.startsWith('/')) {
return `${siteUrl}${url}`
}
return `${siteUrl}/${url.replace(/^\/+/, '')}`
}
/**
* RSS 날짜 문자열을 만든다.
* @param {unknown} value - ISO 날짜 문자열
* @returns {string} RSS 날짜 문자열
*/
const formatRssDate = (value) => {
const date = value ? new Date(value) : new Date()
if (Number.isNaN(date.getTime())) {
return new Date().toUTCString()
}
return date.toUTCString()
}
/**
* 게시물 공개 URL을 만든다.
* @param {Object} post - 게시물
* @param {string} siteUrl - 사이트 URL
* @returns {string} 게시물 URL
*/
const getPostUrl = (post, siteUrl) => `${siteUrl}/post/${encodeURIComponent(post.slug)}`
/**
* 게시물 RSS 썸네일 URL을 반환한다.
* @param {Object} post - 게시물
* @param {string} siteUrl - 사이트 URL
* @returns {string} 썸네일 절대 URL
*/
const getPostThumbnailUrl = (post, siteUrl) => toAbsoluteUrl(post.ogImage || post.featuredImage, siteUrl)
/**
* RSS item XML을 만든다.
* @param {Object} post - 게시물
* @param {string} siteUrl - 사이트 URL
* @returns {string} RSS item XML
*/
const buildRssItem = (post, siteUrl) => {
const postUrl = getPostUrl(post, siteUrl)
const thumbnailUrl = getPostThumbnailUrl(post, siteUrl)
const description = createPostSummary(post.excerpt, post.content, {
maxLength: 280,
appendEllipsis: true
})
const publishedAt = post.publishedAt || post.createdAt
return [
' <item>',
` <title>${escapeXml(post.title || 'Untitled')}</title>`,
` <link>${escapeXml(postUrl)}</link>`,
` <guid isPermaLink="true">${escapeXml(postUrl)}</guid>`,
` <pubDate>${escapeXml(formatRssDate(publishedAt))}</pubDate>`,
thumbnailUrl ? ` <media:thumbnail url="${escapeXml(thumbnailUrl)}" />` : '',
thumbnailUrl ? ` <media:content url="${escapeXml(thumbnailUrl)}" medium="image" />` : '',
description ? ` <description>${escapeXml(description)}</description>` : '',
' </item>'
].filter(Boolean).join('\n')
}
/**
* 공개 RSS XML을 만든다.
* @param {string} selfPath - 현재 피드 경로
* @param {string} fallbackSiteUrl - 사이트 설정 URL이 없을 때 사용할 요청 URL
* @returns {Promise<string>} RSS XML
*/
export const buildRssFeed = async (selfPath = '/rss.xml', fallbackSiteUrl = '') => {
const [settings, posts] = await Promise.all([
getSiteSettings(),
listPosts({ includeMembership: false })
])
const siteUrl = normalizeSiteUrl(settings.siteUrl || fallbackSiteUrl)
const feedUrl = `${siteUrl}${selfPath}`
const visiblePosts = posts.slice(0, RSS_POST_LIMIT)
const lastBuildDate = visiblePosts[0]?.updatedAt || visiblePosts[0]?.publishedAt || new Date().toISOString()
const items = visiblePosts.map((post) => buildRssItem(post, siteUrl)).join('\n')
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">',
' <channel>',
` <title>${escapeXml(settings.title)}</title>`,
` <link>${escapeXml(siteUrl)}</link>`,
` <description>${escapeXml(settings.description)}</description>`,
' <language>ko</language>',
` <lastBuildDate>${escapeXml(formatRssDate(lastBuildDate))}</lastBuildDate>`,
` <atom:link href="${escapeXml(feedUrl)}" rel="self" type="application/rss+xml" />`,
items,
' </channel>',
'</rss>'
].filter(Boolean).join('\n')
}