import { getSiteSettings, listPages, listPosts, listTags } from '../repositories/content-repository'
/**
* 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 {string} path - 사이트 내부 경로
* @param {string} siteUrl - 사이트 URL
* @returns {string} 절대 URL
*/
const toSiteUrl = (path, siteUrl) => `${siteUrl}${path.startsWith('/') ? path : `/${path}`}`
/**
* sitemap 날짜 문자열을 만든다.
* @param {unknown} value - ISO 날짜 문자열
* @returns {string} sitemap lastmod 문자열
*/
const formatSitemapDate = (value) => {
const date = value ? new Date(value) : new Date()
if (Number.isNaN(date.getTime())) {
return new Date().toISOString()
}
return date.toISOString()
}
/**
* sitemap URL 항목 XML을 만든다.
* @param {Object} item - URL 항목
* @param {string} item.loc - 절대 URL
* @param {string} [item.lastmod] - 마지막 수정 시각
* @param {string} [item.changefreq] - 변경 빈도
* @param {string} [item.priority] - 우선순위
* @returns {string} URL 항목 XML
*/
const buildUrlItem = ({ loc, lastmod = '', changefreq = '', priority = '' }) => [
' ',
` ${escapeXml(loc)}`,
lastmod ? ` ${escapeXml(formatSitemapDate(lastmod))}` : '',
changefreq ? ` ${escapeXml(changefreq)}` : '',
priority ? ` ${escapeXml(priority)}` : '',
' '
].filter(Boolean).join('\n')
/**
* 공개 sitemap XML을 만든다.
* @param {string} fallbackSiteUrl - 사이트 설정 URL이 없을 때 사용할 요청 URL
* @returns {Promise} sitemap XML
*/
export const buildSitemap = async (fallbackSiteUrl = '') => {
const [settings, posts, pages, tags] = await Promise.all([
getSiteSettings(),
listPosts({ includeMembership: false }),
listPages(),
listTags({ tagType: 'managed' })
])
const siteUrl = normalizeSiteUrl(settings.siteUrl || fallbackSiteUrl)
const now = new Date().toISOString()
const publicPosts = posts.filter((post) => !post.noindex)
const visibleTags = tags.filter((tag) => Number(tag.postCount || 0) > 0)
const items = [
{
loc: toSiteUrl('/', siteUrl),
lastmod: settings.updatedAt || now,
changefreq: 'daily',
priority: '1.0'
},
{
loc: toSiteUrl('/posts', siteUrl),
lastmod: publicPosts[0]?.updatedAt || now,
changefreq: 'daily',
priority: '0.8'
},
{
loc: toSiteUrl('/tags', siteUrl),
lastmod: visibleTags[0]?.updatedAt || now,
changefreq: 'weekly',
priority: '0.5'
},
...publicPosts.map((post) => ({
loc: toSiteUrl(`/post/${encodeURIComponent(post.slug)}`, siteUrl),
lastmod: post.updatedAt || post.publishedAt || post.createdAt,
changefreq: 'weekly',
priority: post.isFeatured ? '0.9' : '0.7'
})),
...pages.map((page) => ({
loc: toSiteUrl(`/pages/${encodeURIComponent(page.slug)}`, siteUrl),
lastmod: page.updatedAt || page.createdAt,
changefreq: 'monthly',
priority: '0.6'
})),
...visibleTags.map((tag) => ({
loc: toSiteUrl(`/tag/${encodeURIComponent(tag.slug)}`, siteUrl),
lastmod: tag.lastUsedAt || tag.updatedAt || now,
changefreq: 'weekly',
priority: '0.5'
}))
]
return [
'',
'',
items.map(buildUrlItem).join('\n'),
''
].join('\n')
}
/**
* robots.txt 본문을 만든다.
* @param {string} fallbackSiteUrl - 사이트 설정 URL이 없을 때 사용할 요청 URL
* @returns {Promise} robots.txt 본문
*/
export const buildRobotsTxt = async (fallbackSiteUrl = '') => {
const settings = await getSiteSettings()
const siteUrl = normalizeSiteUrl(settings.siteUrl || fallbackSiteUrl)
return [
'User-agent: *',
'Disallow: /admin',
'Disallow: /api/',
'Disallow: /settings',
'Disallow: /signin',
`Sitemap: ${toSiteUrl('/sitemap.xml', siteUrl)}`,
''
].join('\n')
}