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, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
/**
* 사이트 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 [
' - ',
` ${escapeXml(post.title || 'Untitled')}`,
` ${escapeXml(postUrl)}`,
` ${escapeXml(postUrl)}`,
` ${escapeXml(formatRssDate(publishedAt))}`,
thumbnailUrl ? ` ` : '',
thumbnailUrl ? ` ` : '',
description ? ` ${escapeXml(description)}` : '',
'
'
].filter(Boolean).join('\n')
}
/**
* 공개 RSS XML을 만든다.
* @param {string} selfPath - 현재 피드 경로
* @param {string} fallbackSiteUrl - 사이트 설정 URL이 없을 때 사용할 요청 URL
* @returns {Promise} 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 [
'',
'',
' ',
` ${escapeXml(settings.title)}`,
` ${escapeXml(siteUrl)}`,
` ${escapeXml(settings.description)}`,
' ko',
` ${escapeXml(formatRssDate(lastBuildDate))}`,
` `,
items,
' ',
''
].filter(Boolean).join('\n')
}