diff --git a/docs/changelog.md b/docs/changelog.md index 7f1cd6f..7ee9529 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,11 @@ # 업데이트 요약 +## v1.5.82 + +- `/sitemap.xml`을 추가해 공개 게시물·페이지·태그 URL을 검색엔진에 전달할 수 있게 했다. +- `/robots.txt`에서 sitemap 위치를 안내하도록 했다. +- sitemap은 요청 시점 기준으로 자동 생성되므로 새 게시물마다 파일을 직접 갱신하지 않아도 된다. + ## v1.5.81 - 글쓰기 오른쪽 사이드에서 `대표 이미지 표시` 토글이 항상 보이고 동작하도록 정리했다. diff --git a/docs/deploy.md b/docs/deploy.md index 0d12089..88d5549 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -1,6 +1,6 @@ # 배포 가이드 -> 로컬 기준 v1.5.81에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. +> 로컬 기준 v1.5.82에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. ## 빌드 유형 @@ -16,6 +16,14 @@ ## 로컬 개발 +### v1.5.82 참고 + +- 추가 DB 마이그레이션은 없다. +- 배포 후 `/sitemap.xml`이 `application/xml`로 응답하고 공개 발행글·고정 페이지·태그 URL을 포함하는지 확인한다. +- `noindex` 글, 멤버십·비공개·초안·예약 대기 글이 `/sitemap.xml`에 포함되지 않는지 확인한다. +- `/robots.txt`가 `Sitemap: https://.../sitemap.xml` 절대 URL을 포함하는지 확인한다. +- Google Search Console에는 `https://도메인/sitemap.xml`을 제출한다. 이후 새 게시물 발행 시 sitemap 파일을 수동 갱신할 필요는 없다. + ### v1.5.81 참고 - 추가 DB 마이그레이션은 없다. diff --git a/docs/history.md b/docs/history.md index 32129a0..9d7a1e7 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-06-08 v1.5.82 — sitemap은 파일이 아니라 공개 DB 기준 동적 응답으로 제공한다 + +게시물을 발행할 때마다 정적 sitemap 파일을 다시 쓰는 방식은 운영 볼륨과 배포 산출물의 경계가 흐려지고, 예약 발행·검색엔진 노출 제외 상태와 어긋나기 쉽다. sitemap은 요청 시점의 공개 DB 상태에서 만들고, `noindex` 글과 멤버십·비공개·초안·예약 대기 글은 제외한다. robots.txt는 sitemap 위치를 명시해 검색엔진이 RSS나 내부 링크만 추적하지 않아도 공개 URL 목록을 찾을 수 있게 한다. + ## 2026-06-08 v1.5.80 — 카드 썸네일 생성은 대표 이미지 저장 시점으로 제한한다 본문에 첨부한 모든 이미지는 사용자가 콘텐츠 일부로 올린 원본 자산이며, 목록 카드에 쓰이지 않을 수 있다. 업로드 시점에 모든 본문 이미지의 카드 썸네일을 만들면 미디어 관리 화면에 파생 파일이 불필요하게 늘어나고, 어떤 썸네일이 실제로 필요한지 판단하기 어려워진다. 따라서 업로드 API는 원본만 저장하고, 게시물 대표 이미지로 저장되는 이미지에 한해 카드 썸네일을 생성한다. 썸네일 파일이 지워졌거나 누락된 대표 이미지는 관리자 미디어 상세에서 다시 생성할 수 있게 두고, 상세 본문 상단 대표 이미지는 글별 옵션으로 기본 숨김 처리한다. diff --git a/docs/map.md b/docs/map.md index 91109e0..77d5025 100644 --- a/docs/map.md +++ b/docs/map.md @@ -60,11 +60,14 @@ | server/middleware/admin-api-session.js | `/admin/api/*` 요청마다 관리자 세션과 현재 DB 권한(`owner`/`admin`) 재확인 | | server/middleware/html-page-renderer.js | HTML 문서 모드 고정 페이지(`/pages/:slug`)를 Nuxt 렌더링 대신 `text/html` 원문으로 응답 | | server/routes/uploads/[...path].get.js | 런타임 업로드 파일 제공 API(`/app/public/uploads` 볼륨 파일을 `/uploads/**`로 스트리밍) | +| server/routes/sitemap.xml.get.js | 공개 sitemap XML(`/sitemap.xml`) | +| server/routes/robots.txt.get.js | 공개 robots.txt(`/robots.txt`, sitemap 위치 안내) | | server/routes/rss.xml.get.js | 공개 RSS 2.0 피드(`/rss.xml`) | | server/routes/feed.xml.get.js | 공개 RSS 2.0 피드 별칭(`/feed.xml`) | | server/routes/rss.get.js | 공개 RSS 2.0 피드 별칭(`/rss`) | | server/plugins/site-custom-code.js | 공개 Nuxt HTML 응답에 사이트 설정 헤더·푸터 코드 삽입(`/admin`, `/api`, `/uploads`, `/_nuxt`, `/ads.txt` 제외) | | server/utils/post-thumbnail-image.js | 게시물 업로드 이미지의 카드 썸네일 URL·디스크 경로·생성 가능 여부 규칙 | +| server/utils/sitemap.js | 공개 sitemap XML·robots.txt 생성 | | server/utils/rss-feed.js | 공개 발행글 기반 RSS 2.0 XML 생성, 게시물 이미지 Media RSS 썸네일 출력 | ## 사이트 컴포넌트 diff --git a/docs/spec.md b/docs/spec.md index 20e63ca..3f476d3 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -118,6 +118,8 @@ - `/posts` - 게시물 전체 목록 - `/post/:slug` - 개별 게시물 상세 +- `/sitemap.xml` - 공개 sitemap XML. 홈, 게시물 목록, 태그 목록, 공개 발행글, 공개 고정 페이지, 글이 있는 태그 URL을 요청 시점 DB 기준으로 자동 생성한다. 멤버십·비공개·초안·예약 대기 글과 `noindex` 글은 포함하지 않는다. +- `/robots.txt` - 크롤러 안내 파일. 관리자·API·회원 설정·로그인 경로를 제외하고 `Sitemap: /sitemap.xml` 절대 URL을 제공한다. - `/rss.xml`, `/feed.xml`, `/rss` - 최근 공개 발행글 RSS 2.0 피드. 멤버십·비공개·초안·예약 대기 글은 포함하지 않으며, 최신순 최대 50개를 XML로 응답한다. 게시물에 대표 이미지 또는 OG 이미지가 있으면 절대 URL로 변환해 `media:thumbnail`과 `media:content medium="image"`를 함께 포함한다. - `/tags` - 태그 전체 목록 - `/tag/:slug` - 태그별 게시물 목록 diff --git a/docs/update.md b/docs/update.md index 64aa4bc..0fe258f 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 이력 +## v1.5.82 + +- 공개 SEO: `/sitemap.xml` 동적 sitemap 라우트 추가. +- 공개 SEO: `/robots.txt` 라우트 추가 및 sitemap 위치 안내. +- sitemap: 공개 발행글·고정 페이지·글이 있는 태그 URL을 DB 기준으로 자동 생성하고, `noindex` 글은 제외하도록 정리. + ## v1.5.81 - 게시물 글쓰기: 오른쪽 사이드의 `대표 이미지 표시` 토글을 대표 이미지 유무와 상관없이 항상 보이고 동작하도록 수정. diff --git a/package-lock.json b/package-lock.json index 6fcba51..335e56a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.5.81", + "version": "1.5.82", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.5.81", + "version": "1.5.82", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 2275c86..8f6bc73 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.5.81", + "version": "1.5.82", "private": true, "type": "module", "imports": { diff --git a/server/routes/robots.txt.get.js b/server/routes/robots.txt.get.js new file mode 100644 index 0000000..792bd07 --- /dev/null +++ b/server/routes/robots.txt.get.js @@ -0,0 +1,14 @@ +import { getRequestURL, setHeader } from 'h3' +import { buildRobotsTxt } from '../utils/sitemap' + +/** + * robots.txt 응답 + * @param {import('h3').H3Event} event - 요청 이벤트 + * @returns {Promise} robots.txt 본문 + */ +export default defineEventHandler(async (event) => { + setHeader(event, 'Content-Type', 'text/plain; charset=utf-8') + setHeader(event, 'Cache-Control', 'public, max-age=300') + + return buildRobotsTxt(getRequestURL(event).origin) +}) diff --git a/server/routes/sitemap.xml.get.js b/server/routes/sitemap.xml.get.js new file mode 100644 index 0000000..810f2ac --- /dev/null +++ b/server/routes/sitemap.xml.get.js @@ -0,0 +1,14 @@ +import { getRequestURL, setHeader } from 'h3' +import { buildSitemap } from '../utils/sitemap' + +/** + * sitemap.xml 응답 + * @param {import('h3').H3Event} event - 요청 이벤트 + * @returns {Promise} sitemap XML + */ +export default defineEventHandler(async (event) => { + setHeader(event, 'Content-Type', 'application/xml; charset=utf-8') + setHeader(event, 'Cache-Control', 'public, max-age=300') + + return buildSitemap(getRequestURL(event).origin) +}) diff --git a/server/utils/sitemap.js b/server/utils/sitemap.js new file mode 100644 index 0000000..a4b7439 --- /dev/null +++ b/server/utils/sitemap.js @@ -0,0 +1,144 @@ +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') +}