diff --git a/components/content/ContentMarkdownRenderer.vue b/components/content/ContentMarkdownRenderer.vue index 7dd603c..9983262 100644 --- a/components/content/ContentMarkdownRenderer.vue +++ b/components/content/ContentMarkdownRenderer.vue @@ -4,11 +4,11 @@ import { getImageDisplayCaption } from '../../lib/markdown-image.js' import { - getPostBodyPreviewThumbnailUrl, - getPostBodyThumbnailUrl, - getPostLightboxThumbnailUrl, - withGeneratedImageCacheVersion -} from '../../lib/post-card-thumbnail-url.js' + getContentDisplayImageSrcset, + getContentDisplayImageUrl, + getContentLightboxImageUrl, + getGalleryImageSizes +} from '../../lib/content-image-urls.js' import { readEditableTextFromElement, setEditableCaretOffset } from '../../lib/markdown-inline.js' import { applyLiveSelectionDelete, @@ -232,58 +232,6 @@ const contentLightboxImages = computed(() => { return images }) -/** - * 게시물 본문 표시용 이미지 URL을 조회한다. - * @param {string|null|undefined} imageUrl - 원본 이미지 URL - * @returns {string} 표시용 이미지 URL - */ -const getContentDisplayImageUrl = (imageUrl) => { - const bodyThumbnailUrl = getPostBodyThumbnailUrl(imageUrl) - - return bodyThumbnailUrl - ? withGeneratedImageCacheVersion(bodyThumbnailUrl) - : imageUrl || '' -} - -/** - * 게시물 라이트박스용 이미지 URL을 조회한다. - * @param {string|null|undefined} imageUrl - 원본 이미지 URL - * @returns {string} 라이트박스용 이미지 URL - */ -const getContentLightboxImageUrl = (imageUrl) => { - const lightboxThumbnailUrl = getPostLightboxThumbnailUrl(imageUrl) - - return lightboxThumbnailUrl - ? withGeneratedImageCacheVersion(lightboxThumbnailUrl) - : imageUrl || '' -} - -/** - * 게시물 본문 이미지의 반응형 srcset을 만든다. - * @param {string|null|undefined} imageUrl - 원본 이미지 URL - * @returns {string} srcset 문자열 - */ -const getContentDisplayImageSrcset = (imageUrl) => { - const previewUrl = getPostBodyPreviewThumbnailUrl(imageUrl) - const bodyUrl = getPostBodyThumbnailUrl(imageUrl) - - return [ - previewUrl ? `${withGeneratedImageCacheVersion(previewUrl)} 720w` : '', - bodyUrl ? `${withGeneratedImageCacheVersion(bodyUrl)} 1600w` : '' - ].filter(Boolean).join(', ') -} - -/** - * 갤러리 표시용 이미지 sizes 값을 만든다. - * @param {number} rowLength - 갤러리 행 이미지 개수 - * @returns {string} sizes 속성 - */ -const getGalleryImageSizes = (rowLength) => { - const columnWidth = rowLength >= 3 ? '33vw' : rowLength === 2 ? '50vw' : '100vw' - - return `(max-width: 768px) 100vw, ${columnWidth}` -} - /** * 갤러리 이미지를 행 단위로 최대 3개씩 나눈다. * @param {Array} images - 갤러리 이미지 목록 diff --git a/docs/map.md b/docs/map.md index 384c8a4..a8b03b5 100644 --- a/docs/map.md +++ b/docs/map.md @@ -33,6 +33,7 @@ |------|------| | lib/upload-size-limit.js | 관리자 미디어 업로드 종류별 최대 바이트 판별·1회 파일 수/총량 제한 정규화·용량 문구(서버·클라이언트 공용) | | lib/content-in-article-ad.js | 공개 Markdown 본문 인아티클 광고 삽입 후보·목표 위치 계산 | +| lib/content-image-urls.js | 공개 Markdown 본문 이미지 표시 URL·라이트박스 URL·srcset·갤러리 sizes 계산 | | lib/admin-social-settings-section.js | 관리자 설정 SNS 설정 필드 목록·기본 스냅샷·정규화·스냅샷 복사·변경 비교 helper | | lib/admin-post-settings-section.js | 관리자 설정 POST 설정 필드 목록·기본 스냅샷·정규화·스냅샷 복사·변경 비교 helper | | lib/admin-spam-settings-section.js | 관리자 설정 스팸 필터 필드 목록·기본 스냅샷·정규화·textarea 변환·스냅샷 복사·변경 비교 helper | @@ -110,6 +111,7 @@ | docker-compose.test.yml | 실행마다 생성·폐기하는 PostgreSQL 16 테스트 DB·볼륨·네트워크 | | tests/markdown-html-block.test.js | HTML sanitizer 단위 회귀 테스트 | | tests/content-in-article-ad.test.js | 공개 본문 인아티클 광고 삽입 위치 계산 단위 회귀 테스트 | +| tests/content-image-urls.test.js | 공개 본문 이미지 파생 URL·srcset·갤러리 sizes 단위 회귀 테스트 | | tests/markdown-block-context.test.js | 관리자 블록 설정 패널의 활성 블록 컨텍스트 단위 회귀 테스트 | | tests/markdown-block-parser.test.js | 공개 Markdown 본문 블록 파싱 단위 회귀 테스트 | | tests/markdown-fenced-meta.test.js | Markdown fenced 블록 시작·종료 줄 탐색, 본문 수집, 메타 파싱 단위 회귀 테스트 | diff --git a/docs/spec.md b/docs/spec.md index 1545cca..e71a7fc 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -127,8 +127,8 @@ - 관리자 미디어 업로드 API는 JPG·PNG·WebP 파일을 `/uploads/posts/YYYY/MM/원본파일`에 저장한다. 업로드만으로는 카드 썸네일을 만들지 않고, 해당 이미지가 게시물 대표 이미지로 저장될 때 목록 카드용 WebP 썸네일을 `/uploads/posts/YYYY/MM/thumbs/원본파일명-card.webp`에 생성한다. - 카드 썸네일은 640×360 기준 `cover` 리사이즈와 WebP 품질 82를 사용한다. GIF·동영상·문서 파일은 썸네일을 자동 생성하지 않는다. -- 본문 표시용 이미지는 1600px `thumbs/*-body-1600.webp`와 720px `thumbs/*-body-720.webp`를 함께 생성하고, 공개 본문 단일 이미지·갤러리는 `srcset`으로 화면 폭에 맞는 파일을 우선 사용한다. -- 라이트박스용 이미지는 2560px `thumbs/*-body-2560.webp`를 생성해 크게 보기 화면에서 우선 사용한다. 별도 원본 보기 UI는 제공하지 않으며, 파생 이미지 로드 실패 시에만 원본으로 fallback한다. +- 본문 표시용 이미지는 1600px `thumbs/*-body-1600.webp`와 720px `thumbs/*-body-720.webp`를 함께 생성하고, 공개 본문 단일 이미지·갤러리는 `lib/content-image-urls.js`의 `srcset` 계산으로 화면 폭에 맞는 파일을 우선 사용한다. +- 라이트박스용 이미지는 2560px `thumbs/*-body-2560.webp`를 생성해 크게 보기 화면에서 우선 사용한다. 별도 원본 보기 UI는 제공하지 않으며, `lib/content-image-urls.js`가 파생 URL을 선택하고 파생 이미지 로드 실패 시에만 원본으로 fallback한다. - 기존 운영 볼륨에 생성된 `thumbs/*-body.webp`, `thumbs/*-lightbox.webp`는 레거시 본문 파생 이미지로 계속 인식해 관리자 미디어의 본문 이미지 탭과 사용처 연결을 유지한다. 새 저장·재생성·백필은 해상도형 파일명만 만든다. - 관리자 미디어의 원본 이미지 사용처는 원본 URL 직접 참조뿐 아니라 해당 원본에서 생성된 카드·페이지 커버·본문 파생 이미지 URL 사용처까지 함께 반영한다. 사용처 계산은 관리자 글 목록 요약 API와 분리된 미디어 참조 전용 조회로 게시물·페이지의 본문과 대표 이미지만 확인한다. 따라서 공개 본문이 `body-1600` 같은 파생 이미지를 사용하더라도 원본은 미사용 삭제 대상에서 제외되고, 본문 파생 이미지도 실제 본문 사용처가 있으면 미사용으로 분류하지 않는다. 원본이 실제로 미사용이라 삭제될 때는 연결된 카드·페이지 커버·본문 자동 생성 이미지도 함께 삭제한다. - 카드·본문·라이트박스·Pages 커버 파생 이미지는 임시 파일에 먼저 생성하고 Sharp 메타데이터로 읽기 검증한 뒤 최종 파일로 교체한다. 생성 또는 검증에 실패하면 깨진 파생 파일이 남지 않도록 대상 파일을 제거하고 공개 화면은 원본 fallback을 사용한다. 파생 이미지는 같은 URL로 재생성될 수 있으므로 `/uploads/posts/YYYY/MM/thumbs/*.webp` 응답은 `no-cache`로 재검증하고, 공개 본문과 관리자 미디어 미리보기에는 파생 이미지 캐시 버전 쿼리를 붙여 기존 브라우저 캐시를 우회한다. diff --git a/docs/update.md b/docs/update.md index 372eaa6..1696e59 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 이력 +## v1.11.55 + +- 공개 Markdown 렌더러의 본문 이미지 표시·라이트박스·srcset·갤러리 sizes 계산을 `lib/content-image-urls.js`로 분리. +- 업로드 원본 이미지의 body-720/body-1600/body-2560 파생 URL 선택과 외부 이미지 fallback 단위 테스트를 추가. +- `ContentMarkdownRenderer`는 이미지 렌더링 상태와 이벤트 연결에 집중하도록 정리. + ## v1.11.54 - 공개 Markdown 렌더러의 블록 파싱 로직을 `lib/markdown-block-parser.js`로 분리. diff --git a/lib/content-image-urls.js b/lib/content-image-urls.js new file mode 100644 index 0000000..a9a5e8f --- /dev/null +++ b/lib/content-image-urls.js @@ -0,0 +1,58 @@ +import { + getPostBodyPreviewThumbnailUrl, + getPostBodyThumbnailUrl, + getPostLightboxThumbnailUrl, + withGeneratedImageCacheVersion +} from './post-card-thumbnail-url.js' + +/** + * 게시물 본문 표시용 이미지 URL을 조회한다. + * @param {string|null|undefined} imageUrl - 원본 이미지 URL + * @returns {string} 표시용 이미지 URL + */ +export const getContentDisplayImageUrl = (imageUrl) => { + const bodyThumbnailUrl = getPostBodyThumbnailUrl(imageUrl) + + return bodyThumbnailUrl + ? withGeneratedImageCacheVersion(bodyThumbnailUrl) + : imageUrl || '' +} + +/** + * 게시물 라이트박스용 이미지 URL을 조회한다. + * @param {string|null|undefined} imageUrl - 원본 이미지 URL + * @returns {string} 라이트박스용 이미지 URL + */ +export const getContentLightboxImageUrl = (imageUrl) => { + const lightboxThumbnailUrl = getPostLightboxThumbnailUrl(imageUrl) + + return lightboxThumbnailUrl + ? withGeneratedImageCacheVersion(lightboxThumbnailUrl) + : imageUrl || '' +} + +/** + * 게시물 본문 이미지의 반응형 srcset을 만든다. + * @param {string|null|undefined} imageUrl - 원본 이미지 URL + * @returns {string} srcset 문자열 + */ +export const getContentDisplayImageSrcset = (imageUrl) => { + const previewUrl = getPostBodyPreviewThumbnailUrl(imageUrl) + const bodyUrl = getPostBodyThumbnailUrl(imageUrl) + + return [ + previewUrl ? `${withGeneratedImageCacheVersion(previewUrl)} 720w` : '', + bodyUrl ? `${withGeneratedImageCacheVersion(bodyUrl)} 1600w` : '' + ].filter(Boolean).join(', ') +} + +/** + * 갤러리 표시용 이미지 sizes 값을 만든다. + * @param {number} rowLength - 갤러리 행 이미지 개수 + * @returns {string} sizes 속성 + */ +export const getGalleryImageSizes = (rowLength) => { + const columnWidth = rowLength >= 3 ? '33vw' : rowLength === 2 ? '50vw' : '100vw' + + return `(max-width: 768px) 100vw, ${columnWidth}` +} diff --git a/package-lock.json b/package-lock.json index 4911755..38e3e12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.11.54", + "version": "1.11.55", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.11.54", + "version": "1.11.55", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 88a1449..259c605 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.11.54", + "version": "1.11.55", "private": true, "type": "module", "imports": { diff --git a/tests/content-image-urls.test.js b/tests/content-image-urls.test.js new file mode 100644 index 0000000..851141d --- /dev/null +++ b/tests/content-image-urls.test.js @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + getContentDisplayImageSrcset, + getContentDisplayImageUrl, + getContentLightboxImageUrl, + getGalleryImageSizes +} from '../lib/content-image-urls.js' + +test('본문 이미지 URL은 업로드 원본이면 body-1600 파생 이미지로 변환한다', () => { + assert.equal( + getContentDisplayImageUrl('/uploads/posts/2026/07/sample.jpg'), + '/uploads/posts/2026/07/thumbs/sample-body-1600.webp?v=20260702-1' + ) +}) + +test('라이트박스 이미지 URL은 업로드 원본이면 body-2560 파생 이미지로 변환한다', () => { + assert.equal( + getContentLightboxImageUrl('/uploads/posts/2026/07/sample.png'), + '/uploads/posts/2026/07/thumbs/sample-body-2560.webp?v=20260702-1' + ) +}) + +test('외부 이미지는 원본 URL을 그대로 사용하고 srcset을 만들지 않는다', () => { + const url = 'https://example.com/image.jpg' + + assert.equal(getContentDisplayImageUrl(url), url) + assert.equal(getContentLightboxImageUrl(url), url) + assert.equal(getContentDisplayImageSrcset(url), '') +}) + +test('본문 이미지 srcset은 720과 1600 파생 이미지를 함께 제공한다', () => { + assert.equal( + getContentDisplayImageSrcset('/uploads/posts/2026/07/sample.webp'), + [ + '/uploads/posts/2026/07/thumbs/sample-body-720.webp?v=20260702-1 720w', + '/uploads/posts/2026/07/thumbs/sample-body-1600.webp?v=20260702-1 1600w' + ].join(', ') + ) +}) + +test('갤러리 sizes는 행 이미지 개수 기준으로 계산한다', () => { + assert.equal(getGalleryImageSizes(1), '(max-width: 768px) 100vw, 100vw') + assert.equal(getGalleryImageSizes(2), '(max-width: 768px) 100vw, 50vw') + assert.equal(getGalleryImageSizes(3), '(max-width: 768px) 100vw, 33vw') +})