본문 이미지 URL 계산 분리

This commit is contained in:
2026-07-24 15:35:11 +09:00
parent c833d47a2c
commit 544d4cb1c1
8 changed files with 122 additions and 62 deletions

View File

@@ -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<Object>} images - 갤러리 이미지 목록

View File

@@ -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 블록 시작·종료 줄 탐색, 본문 수집, 메타 파싱 단위 회귀 테스트 |

View File

@@ -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`로 재검증하고, 공개 본문과 관리자 미디어 미리보기에는 파생 이미지 캐시 버전 쿼리를 붙여 기존 브라우저 캐시를 우회한다.

View File

@@ -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`로 분리.

58
lib/content-image-urls.js Normal file
View File

@@ -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}`
}

4
package-lock.json generated
View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.11.54",
"version": "1.11.55",
"private": true,
"type": "module",
"imports": {

View File

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