diff --git a/docs/changelog.md b/docs/changelog.md index 5116afd..c665189 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,10 @@ # 업데이트 요약 +## v1.5.43 + +- `/rss.xml`, `/feed.xml`, `/rss`에서 최근 공개 발행글 RSS 피드를 제공한다. +- SNS 설정의 RSS 프리셋 기본 주소를 `/rss.xml`로 맞췄다. + ## v1.5.42 - 직접 SVG로 등록한 SNS 아이콘도 기존 SNS 아이콘과 같은 크기와 중앙 정렬로 표시되게 정리했다. diff --git a/docs/deploy.md b/docs/deploy.md index 365a2ec..c528671 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -1,6 +1,6 @@ # 배포 가이드 -> 로컬 기준 v1.5.42에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. +> 로컬 기준 v1.5.43에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. ## 빌드 유형 @@ -68,6 +68,12 @@ docker exec sori-studio-db pg_isready -U sori_studio -d sori_studio docker exec sori-studio-db psql -U sori_studio -d sori_studio -c 'SELECT count(*) AS posts_count FROM posts;' ``` +### v1.5.43 참고 + +- 추가 DB 마이그레이션은 없다. +- 배포 후 `/rss.xml`, `/feed.xml`, `/rss`가 `application/rss+xml`로 응답하고 최근 공개 발행글을 포함하는지 확인한다. +- 관리자 SNS 정보에서 RSS 프리셋을 사용할 경우 주소는 `/rss.xml`을 권장한다. + ### v1.5.42 참고 - 추가 DB 마이그레이션은 없다. diff --git a/docs/history.md b/docs/history.md index 3e99439..801f958 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-06-02 v1.5.43 — RSS는 공개 발행글 피드로만 제공한다 + +RSS 리더는 공개 화면을 구독하는 용도이므로 멤버십, 비공개, 초안, 아직 발행 시간이 오지 않은 예약 글은 피드에 포함하지 않는다. 호환성을 위해 대표 경로 `/rss.xml`과 흔히 쓰는 `/feed.xml`, `/rss` 별칭을 함께 제공하되, 관리자 SNS 프리셋은 표준적인 `/rss.xml`을 기본 주소로 둔다. + ## 2026-06-02 v1.5.41 — SNS 편집은 아이콘과 주소만으로 충분하다 FOLLOW 영역은 아이콘 버튼 목록이므로 운영자가 직접 보는 편집 화면에서도 핵심 입력은 아이콘과 주소다. 이름은 접근성 라벨로 내부에서 프리셋명을 쓰면 충분하고, 별도 입력칸은 레이아웃을 복잡하게 만든다. 저장값은 JSONB 배열이어야 하므로 기존 문자열 저장값은 마이그레이션으로 복구하고, 저장 쿼리도 명시적으로 JSONB 배열로 캐스팅한다. diff --git a/docs/map.md b/docs/map.md index 7117207..ad2fac0 100644 --- a/docs/map.md +++ b/docs/map.md @@ -58,7 +58,11 @@ | 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/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/rss-feed.js | 공개 발행글 기반 RSS 2.0 XML 생성 | ## 사이트 컴포넌트 diff --git a/docs/spec.md b/docs/spec.md index 35d22e4..5956450 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -106,6 +106,7 @@ - `/posts` - 게시물 전체 목록 - `/post/:slug` - 개별 게시물 상세 +- `/rss.xml`, `/feed.xml`, `/rss` - 최근 공개 발행글 RSS 2.0 피드. 멤버십·비공개·초안·예약 대기 글은 포함하지 않으며, 최신순 최대 50개를 XML로 응답한다. - `/tags` - 태그 전체 목록 - `/tag/:slug` - 태그별 게시물 목록 - `/tag/:slug` 화면의 헤더와 게시물 목록은 공개 목록 공통 섹션 패딩(`site-section-header`, `site-section-body`)을 사용해 메인 컬럼 좌우 여백을 다른 목록 화면과 맞춘다. diff --git a/docs/update.md b/docs/update.md index 1661447..d264c5d 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 이력 +## v1.5.43 + +- 공개 RSS 피드: `/rss.xml`, `/feed.xml`, `/rss` 경로 추가. +- 공개 RSS 피드: 최근 공개 발행글 최대 50개를 RSS 2.0 XML로 응답하도록 추가. +- 사이트 설정 SNS 프리셋: RSS 기본 주소를 실제 피드 경로 `/rss.xml`로 변경. + ## v1.5.42 - 공개 오른쪽 사이드바: 직접 SVG SNS 아이콘도 기존 프리셋 아이콘과 같은 16px 크기와 중앙 정렬로 표시되도록 수정. diff --git a/lib/social-links.js b/lib/social-links.js index 18ee647..1b953df 100644 --- a/lib/social-links.js +++ b/lib/social-links.js @@ -9,7 +9,7 @@ export const SOCIAL_ICON_PRESETS = [ { icon: 'youtube', label: 'YouTube', placeholder: 'https://youtube.com/...' }, { icon: 'facebook', label: 'Facebook', placeholder: 'https://facebook.com/...' }, { icon: 'linkedin', label: 'LinkedIn', placeholder: 'https://linkedin.com/in/...' }, - { icon: 'rss', label: 'RSS', placeholder: '/rss/' }, + { icon: 'rss', label: 'RSS', placeholder: '/rss.xml' }, { icon: 'custom', label: '직접 SVG', placeholder: 'https://...' }, { icon: 'link', label: 'Link', placeholder: 'https://...' } ] diff --git a/package-lock.json b/package-lock.json index 696f8d5..70701b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.5.42", + "version": "1.5.43", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.5.42", + "version": "1.5.43", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 2c06a18..00899c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.5.42", + "version": "1.5.43", "private": true, "type": "module", "imports": { diff --git a/server/routes/feed.xml.get.js b/server/routes/feed.xml.get.js new file mode 100644 index 0000000..30edc2a --- /dev/null +++ b/server/routes/feed.xml.get.js @@ -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} RSS XML + */ +export default defineEventHandler(async (event) => { + setHeader(event, 'Content-Type', 'application/rss+xml; charset=utf-8') + + return buildRssFeed('/feed.xml', getRequestURL(event).origin) +}) diff --git a/server/routes/rss.get.js b/server/routes/rss.get.js new file mode 100644 index 0000000..193e26d --- /dev/null +++ b/server/routes/rss.get.js @@ -0,0 +1,13 @@ +import { getRequestURL, setHeader } from 'h3' +import { buildRssFeed } from '../utils/rss-feed' + +/** + * /rss 별칭 RSS 응답 + * @param {import('h3').H3Event} event - 요청 이벤트 + * @returns {Promise} RSS XML + */ +export default defineEventHandler(async (event) => { + setHeader(event, 'Content-Type', 'application/rss+xml; charset=utf-8') + + return buildRssFeed('/rss', getRequestURL(event).origin) +}) diff --git a/server/routes/rss.xml.get.js b/server/routes/rss.xml.get.js new file mode 100644 index 0000000..299c5c8 --- /dev/null +++ b/server/routes/rss.xml.get.js @@ -0,0 +1,13 @@ +import { getRequestURL, setHeader } from 'h3' +import { buildRssFeed } from '../utils/rss-feed' + +/** + * RSS XML 응답 + * @param {import('h3').H3Event} event - 요청 이벤트 + * @returns {Promise} RSS XML + */ +export default defineEventHandler(async (event) => { + setHeader(event, 'Content-Type', 'application/rss+xml; charset=utf-8') + + return buildRssFeed('/rss.xml', getRequestURL(event).origin) +}) diff --git a/server/utils/rss-feed.js b/server/utils/rss-feed.js new file mode 100644 index 0000000..e3a8204 --- /dev/null +++ b/server/utils/rss-feed.js @@ -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, ''') + +/** + * 사이트 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 [ + ' ', + ` ${escapeXml(post.title || 'Untitled')}`, + ` ${escapeXml(postUrl)}`, + ` ${escapeXml(postUrl)}`, + ` ${escapeXml(formatRssDate(publishedAt))}`, + 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') +}