From accd933e9958cc6624af457d163be7c5cdc685c8 Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 4 Jun 2026 10:46:17 +0900 Subject: [PATCH] =?UTF-8?q?RSS=20=ED=94=BC=EB=93=9C=20=EC=8D=B8=EB=84=A4?= =?UTF-8?q?=EC=9D=BC=20=EC=A0=95=EB=B3=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog.md | 5 +++++ docs/deploy.md | 8 +++++++- docs/history.md | 4 ++++ docs/map.md | 2 +- docs/spec.md | 2 +- docs/update.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- server/utils/rss-feed.js | 41 +++++++++++++++++++++++++++++++++++++++- 9 files changed, 67 insertions(+), 7 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 15c96c2..eeb03ca 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,10 @@ # 업데이트 요약 +## v1.5.47 + +- RSS 피드에 게시물 썸네일 정보를 직접 포함해 RSS 리더에서 대표 이미지가 더 안정적으로 보이도록 했다. +- RSS의 상대 이미지 경로를 절대 URL로 변환한다. + ## v1.5.46 - 콜아웃 배경색도 인용 블록과 같은 6색 팔레트로 맞추고 분홍 선택지를 제거했다. diff --git a/docs/deploy.md b/docs/deploy.md index d49019f..d0ff5ee 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -1,6 +1,6 @@ # 배포 가이드 -> 로컬 기준 v1.5.46에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. +> 로컬 기준 v1.5.47에서 `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.47 참고 + +- 추가 DB 마이그레이션은 없다. +- 대표 이미지가 있는 공개 게시물이 RSS item에 `media:thumbnail`과 `media:content`를 포함하는지 확인한다. +- 상대 이미지 URL이 RSS에서 절대 URL로 변환되는지 확인한다. + ### v1.5.46 참고 - 추가 DB 마이그레이션은 없다. diff --git a/docs/history.md b/docs/history.md index 84759dd..8185c4a 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-06-04 v1.5.47 — RSS 썸네일은 피드 XML에 명시한다 + +게시물 상세 페이지에는 대표 이미지 기반 `og:image`가 있지만, RSS 리더가 게시물 링크를 다시 크롤링해 OG 이미지를 읽는지는 리더마다 다르다. 피드 구독 화면에서 썸네일 노출을 더 안정적으로 만들기 위해 RSS item에 Media RSS 확장인 `media:thumbnail`과 `media:content`를 직접 포함한다. 저장된 이미지 경로가 상대 경로일 수 있으므로 피드 생성 시 사이트 URL 기준 절대 URL로 변환한다. + ## 2026-06-04 v1.5.46 — 콜아웃과 인용 팔레트는 하나로 맞춘다 콜아웃과 인용은 모두 작성자가 본문 안에서 특정 문맥을 강조하는 블록이므로 배경색 선택지가 서로 다르면 오른쪽 설정 패널의 의미가 흐려진다. 분홍은 빨강과 역할이 겹치고 실제 팔레트 변경 후에도 남아 있던 선택지라 제거하고, 두 블록 모두 같은 6색 팔레트를 사용한다. 라이브 편집의 방향키 이동은 옵션 선언 줄처럼 화면에 직접 편집 영역이 없는 줄을 건너뛰어야 하므로, 탐색 대상은 실제 contenteditable 줄과 카드형 블록으로 제한한다. 작은 화면의 게시물 설정 패널은 문서를 밀어내지 않는 고정 오버레이로 두어 본문 폭을 압축하지 않게 한다. diff --git a/docs/map.md b/docs/map.md index 06a5e15..b8fd5bb 100644 --- a/docs/map.md +++ b/docs/map.md @@ -62,7 +62,7 @@ | 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 생성 | +| server/utils/rss-feed.js | 공개 발행글 기반 RSS 2.0 XML 생성, 게시물 이미지 Media RSS 썸네일 출력 | ## 사이트 컴포넌트 diff --git a/docs/spec.md b/docs/spec.md index f363ae6..44f53e1 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -106,7 +106,7 @@ - `/posts` - 게시물 전체 목록 - `/post/:slug` - 개별 게시물 상세 -- `/rss.xml`, `/feed.xml`, `/rss` - 최근 공개 발행글 RSS 2.0 피드. 멤버십·비공개·초안·예약 대기 글은 포함하지 않으며, 최신순 최대 50개를 XML로 응답한다. +- `/rss.xml`, `/feed.xml`, `/rss` - 최근 공개 발행글 RSS 2.0 피드. 멤버십·비공개·초안·예약 대기 글은 포함하지 않으며, 최신순 최대 50개를 XML로 응답한다. 게시물에 대표 이미지 또는 OG 이미지가 있으면 절대 URL로 변환해 `media:thumbnail`과 `media:content medium="image"`를 함께 포함한다. - `/tags` - 태그 전체 목록 - `/tag/:slug` - 태그별 게시물 목록 - `/tag/:slug` 화면의 헤더와 게시물 목록은 공개 목록 공통 섹션 패딩(`site-section-header`, `site-section-body`)을 사용해 메인 컬럼 좌우 여백을 다른 목록 화면과 맞춘다. diff --git a/docs/update.md b/docs/update.md index 6d3213c..0989775 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 이력 +## v1.5.47 + +- 공개 RSS 피드: 게시물 대표 이미지 또는 OG 이미지를 `media:thumbnail`·`media:content`로 포함하도록 추가. +- 공개 RSS 피드: 상대 이미지 경로를 사이트 URL 기준 절대 URL로 변환하도록 추가. +- 공개 RSS 피드: Media RSS 네임스페이스 선언 추가. + ## v1.5.46 - 게시물 글쓰기: 콜아웃 배경색에서 분홍 옵션 제거. diff --git a/package-lock.json b/package-lock.json index 62b6a71..7d15913 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.5.46", + "version": "1.5.47", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.5.46", + "version": "1.5.47", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 0aadc0b..014d826 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.5.46", + "version": "1.5.47", "private": true, "type": "module", "imports": { diff --git a/server/utils/rss-feed.js b/server/utils/rss-feed.js index e3a8204..4b5b799 100644 --- a/server/utils/rss-feed.js +++ b/server/utils/rss-feed.js @@ -22,6 +22,34 @@ const escapeXml = (value) => String(value || '') */ const normalizeSiteUrl = (value) => String(value || '').trim().replace(/\/+$/, '') +/** + * URL을 절대 URL로 변환한다. + * @param {unknown} value - 원본 URL + * @param {string} siteUrl - 사이트 URL + * @returns {string} 절대 URL + */ +const toAbsoluteUrl = (value, siteUrl) => { + const url = String(value || '').trim() + + if (!url) { + return '' + } + + if (/^https?:\/\//i.test(url)) { + return url + } + + if (url.startsWith('//')) { + return `https:${url}` + } + + if (url.startsWith('/')) { + return `${siteUrl}${url}` + } + + return `${siteUrl}/${url.replace(/^\/+/, '')}` +} + /** * RSS 날짜 문자열을 만든다. * @param {unknown} value - ISO 날짜 문자열 @@ -45,6 +73,14 @@ const formatRssDate = (value) => { */ const getPostUrl = (post, siteUrl) => `${siteUrl}/post/${encodeURIComponent(post.slug)}` +/** + * 게시물 RSS 썸네일 URL을 반환한다. + * @param {Object} post - 게시물 + * @param {string} siteUrl - 사이트 URL + * @returns {string} 썸네일 절대 URL + */ +const getPostThumbnailUrl = (post, siteUrl) => toAbsoluteUrl(post.ogImage || post.featuredImage, siteUrl) + /** * RSS item XML을 만든다. * @param {Object} post - 게시물 @@ -53,6 +89,7 @@ const getPostUrl = (post, siteUrl) => `${siteUrl}/post/${encodeURIComponent(post */ const buildRssItem = (post, siteUrl) => { const postUrl = getPostUrl(post, siteUrl) + const thumbnailUrl = getPostThumbnailUrl(post, siteUrl) const description = createPostSummary(post.excerpt, post.content, { maxLength: 280, appendEllipsis: true @@ -65,6 +102,8 @@ const buildRssItem = (post, siteUrl) => { ` ${escapeXml(postUrl)}`, ` ${escapeXml(postUrl)}`, ` ${escapeXml(formatRssDate(publishedAt))}`, + thumbnailUrl ? ` ` : '', + thumbnailUrl ? ` ` : '', description ? ` ${escapeXml(description)}` : '', ' ' ].filter(Boolean).join('\n') @@ -89,7 +128,7 @@ export const buildRssFeed = async (selfPath = '/rss.xml', fallbackSiteUrl = '') return [ '', - '', + '', ' ', ` ${escapeXml(settings.title)}`, ` ${escapeXml(siteUrl)}`,