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') }