RSS 피드 기능 추가
This commit is contained in:
13
server/routes/feed.xml.get.js
Normal file
13
server/routes/feed.xml.get.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { getRequestURL, setHeader } from 'h3'
|
||||
import { buildRssFeed } from '../utils/rss-feed'
|
||||
|
||||
/**
|
||||
* feed.xml RSS 응답
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<string>} RSS XML
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
setHeader(event, 'Content-Type', 'application/rss+xml; charset=utf-8')
|
||||
|
||||
return buildRssFeed('/feed.xml', getRequestURL(event).origin)
|
||||
})
|
||||
13
server/routes/rss.get.js
Normal file
13
server/routes/rss.get.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { getRequestURL, setHeader } from 'h3'
|
||||
import { buildRssFeed } from '../utils/rss-feed'
|
||||
|
||||
/**
|
||||
* /rss 별칭 RSS 응답
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<string>} RSS XML
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
setHeader(event, 'Content-Type', 'application/rss+xml; charset=utf-8')
|
||||
|
||||
return buildRssFeed('/rss', getRequestURL(event).origin)
|
||||
})
|
||||
13
server/routes/rss.xml.get.js
Normal file
13
server/routes/rss.xml.get.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { getRequestURL, setHeader } from 'h3'
|
||||
import { buildRssFeed } from '../utils/rss-feed'
|
||||
|
||||
/**
|
||||
* RSS XML 응답
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<string>} RSS XML
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
setHeader(event, 'Content-Type', 'application/rss+xml; charset=utf-8')
|
||||
|
||||
return buildRssFeed('/rss.xml', getRequestURL(event).origin)
|
||||
})
|
||||
104
server/utils/rss-feed.js
Normal file
104
server/utils/rss-feed.js
Normal file
@@ -0,0 +1,104 @@
|
||||
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, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
/**
|
||||
* 사이트 URL의 끝 슬래시를 제거한다.
|
||||
* @param {unknown} value - 사이트 URL
|
||||
* @returns {string} 정리된 사이트 URL
|
||||
*/
|
||||
const normalizeSiteUrl = (value) => String(value || '').trim().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 item XML을 만든다.
|
||||
* @param {Object} post - 게시물
|
||||
* @param {string} siteUrl - 사이트 URL
|
||||
* @returns {string} RSS item XML
|
||||
*/
|
||||
const buildRssItem = (post, siteUrl) => {
|
||||
const postUrl = getPostUrl(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>`,
|
||||
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">',
|
||||
' <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')
|
||||
}
|
||||
Reference in New Issue
Block a user