본문 이미지 최적화·라이트박스·번호형 페이지네이션 반영 (v1.10.3)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-01 16:19:45 +09:00
parent 0eca6b288e
commit c686b1c848
20 changed files with 812 additions and 143 deletions

View File

@@ -4,7 +4,9 @@ import { createError } from 'h3'
import { getSiteSettings, listAdminPosts, listAdminTags, listPages } from '../repositories/content-repository'
import { getPostgresClient } from '../repositories/postgres-client'
import {
getPostBodyThumbnailUrl,
getPostThumbnailUrl,
isPostBodyThumbnailUrl,
isPostCardThumbnailUrl,
isPostThumbnailSource
} from './post-thumbnail-image.js'
@@ -85,12 +87,13 @@ const assertCategoryMoveAllowed = (urls, normalizedCategory) => {
const uniqueUrls = [...new Set(urls.filter(Boolean))]
const hasAvatar = uniqueUrls.some((u) => isMemberAvatarPublicUrl(u))
const hasPostCardThumbnail = uniqueUrls.some((u) => isPostCardThumbnailUrl(u))
const hasPostBodyThumbnail = uniqueUrls.some((u) => isPostBodyThumbnailUrl(u))
const hasNonAvatar = uniqueUrls.some((u) => !isMemberAvatarPublicUrl(u))
if (hasPostCardThumbnail) {
if (hasPostCardThumbnail || hasPostBodyThumbnail) {
throw createError({
statusCode: 400,
message: '게시물 카드 썸네일은 원본 이미지에 연결된 자동 생성 파일이라 폴더를 변경할 수 없습니다.'
message: '게시물 자동 생성 이미지는 원본 이미지에 연결된 파일이라 폴더를 변경할 수 없습니다.'
})
}
@@ -542,29 +545,53 @@ const getDirectMediaUsage = (url, posts, pages, siteSettings, tags = []) => [
...getTagMediaUsage(url, tags)
]
/**
* 게시물 파생 이미지 URL별 원본 URL 맵을 만든다.
* @param {Array<Object>} items - 미디어 항목 목록
* @param {(url: string) => string} getDerivativeUrl - 원본 URL에서 파생 URL을 만드는 함수
* @param {(url: string) => boolean} isDerivativeUrl - 파생 URL 여부 확인 함수
* @returns {Map<string, string>} 파생 이미지 URL별 원본 URL
*/
const buildPostDerivativeOwnerMap = (items, getDerivativeUrl, isDerivativeUrl) => {
const itemUrlSet = new Set(items.map((item) => item.url))
const ownerByDerivativeUrl = new Map()
for (const item of items) {
if (isDerivativeUrl(item.url) || !isPostThumbnailSource('', item.name)) {
continue
}
const derivativeUrl = getDerivativeUrl(item.url)
if (derivativeUrl && itemUrlSet.has(derivativeUrl)) {
ownerByDerivativeUrl.set(derivativeUrl, item.url)
}
}
return ownerByDerivativeUrl
}
/**
* 카드 썸네일 URL별 원본 URL 맵을 만든다.
* @param {Array<Object>} items - 미디어 항목 목록
* @returns {Map<string, string>} 썸네일 URL별 원본 URL
*/
const buildPostThumbnailOwnerMap = (items) => {
const itemUrlSet = new Set(items.map((item) => item.url))
const ownerByThumbnailUrl = new Map()
const buildPostThumbnailOwnerMap = (items) => buildPostDerivativeOwnerMap(
items,
getPostThumbnailUrl,
isPostCardThumbnailUrl
)
for (const item of items) {
if (isPostCardThumbnailUrl(item.url) || !isPostThumbnailSource('', item.name)) {
continue
}
const thumbnailUrl = getPostThumbnailUrl(item.url)
if (thumbnailUrl && itemUrlSet.has(thumbnailUrl)) {
ownerByThumbnailUrl.set(thumbnailUrl, item.url)
}
}
return ownerByThumbnailUrl
}
/**
* 본문 표시용 이미지 URL별 원본 URL 맵을 만든다.
* @param {Array<Object>} items - 미디어 항목 목록
* @returns {Map<string, string>} 본문 표시용 이미지 URL별 원본 URL
*/
const buildPostBodyThumbnailOwnerMap = (items) => buildPostDerivativeOwnerMap(
items,
getPostBodyThumbnailUrl,
isPostBodyThumbnailUrl
)
/**
* 게시물 카드 썸네일의 간접 사용처를 조회한다.
@@ -592,6 +619,32 @@ const getPostThumbnailMediaUsage = (url, ownerByThumbnailUrl, posts, pages, site
}))
}
/**
* 게시물 본문 표시용 이미지의 간접 사용처를 조회한다.
* @param {string} url - 본문 표시용 이미지 URL
* @param {Map<string, string>} ownerByThumbnailUrl - 본문 표시용 이미지 URL별 원본 URL
* @param {Array<Object>} posts - 게시물 목록
* @param {Array<Object>} pages - 페이지 목록
* @param {Object} siteSettings - 사이트 설정
* @returns {Array<Object>} 간접 사용처 목록
*/
const getPostBodyThumbnailMediaUsage = (url, ownerByThumbnailUrl, posts, pages, siteSettings) => {
const originalUrl = ownerByThumbnailUrl.get(url)
if (!originalUrl) {
return []
}
return getDirectMediaUsage(originalUrl, posts, pages, siteSettings)
.filter((usage) => usage.location === 'content')
.map((usage) => ({
...usage,
location: 'contentBodyImage',
label: '본문 표시 이미지',
sourceUrl: originalUrl
}))
}
/**
* 게시물 업로드 이미지의 카드 썸네일 상태를 조회한다.
* @param {Object} item - 미디어 항목
@@ -600,7 +653,7 @@ const getPostThumbnailMediaUsage = (url, ownerByThumbnailUrl, posts, pages, site
* @returns {Object|null} 카드 썸네일 상태
*/
const getPostThumbnailStatus = (item, itemUrlSet, directUsage) => {
if (isPostCardThumbnailUrl(item.url) || !isPostThumbnailSource('', item.name)) {
if (isPostCardThumbnailUrl(item.url) || isPostBodyThumbnailUrl(item.url) || !isPostThumbnailSource('', item.name)) {
return null
}
@@ -621,6 +674,35 @@ const getPostThumbnailStatus = (item, itemUrlSet, directUsage) => {
}
}
/**
* 게시물 업로드 이미지의 본문 표시용 이미지 상태를 조회한다.
* @param {Object} item - 미디어 항목
* @param {Set<string>} itemUrlSet - 전체 미디어 URL 집합
* @param {Array<Object>} directUsage - 원본 URL 직접 사용처
* @returns {Object|null} 본문 표시용 이미지 상태
*/
const getPostBodyThumbnailStatus = (item, itemUrlSet, directUsage) => {
if (isPostCardThumbnailUrl(item.url) || isPostBodyThumbnailUrl(item.url) || !isPostThumbnailSource('', item.name)) {
return null
}
const thumbnailUrl = getPostBodyThumbnailUrl(item.url)
if (!thumbnailUrl) {
return null
}
const hasThumbnail = itemUrlSet.has(thumbnailUrl)
const isContentImage = directUsage.some((usage) => usage.location === 'content')
return {
role: 'original',
thumbnailUrl,
hasThumbnail,
isFallbackActive: isContentImage && !hasThumbnail
}
}
/**
* 사이트 설정에서 미디어 URL 사용처 조회
* @param {string} url - 미디어 URL
@@ -705,20 +787,24 @@ export const listMediaItems = async () => {
const avatarOwnerByUrl = await getAvatarOwnersByUrls(items.map((item) => item.url))
const itemUrlSet = new Set(items.map((item) => item.url))
const thumbnailOwnerByUrl = buildPostThumbnailOwnerMap(items)
const bodyThumbnailOwnerByUrl = buildPostBodyThumbnailOwnerMap(items)
const itemsWithUsage = items.map((item) => {
const rawCategory = metadataMap[item.url]?.category ?? item.category
const category = normalizeStoredDisplayCategory(item.url, rawCategory)
const avatarOwner = avatarOwnerByUrl.get(item.url) || null
const directUsage = getDirectMediaUsage(item.url, posts, pages, siteSettings, tags)
const thumbnailUsage = getPostThumbnailMediaUsage(item.url, thumbnailOwnerByUrl, posts, pages, siteSettings)
const bodyThumbnailUsage = getPostBodyThumbnailMediaUsage(item.url, bodyThumbnailOwnerByUrl, posts, pages, siteSettings)
const originalUrl = thumbnailOwnerByUrl.get(item.url) || ''
const bodyOriginalUrl = bodyThumbnailOwnerByUrl.get(item.url) || ''
return {
...item,
category,
usage: [
...directUsage,
...thumbnailUsage
...thumbnailUsage,
...bodyThumbnailUsage
],
thumbnailStatus: originalUrl
? {
@@ -726,6 +812,12 @@ export const listMediaItems = async () => {
originalUrl
}
: getPostThumbnailStatus(item, itemUrlSet, directUsage),
bodyThumbnailStatus: bodyOriginalUrl
? {
role: 'body',
originalUrl: bodyOriginalUrl
}
: getPostBodyThumbnailStatus(item, itemUrlSet, directUsage),
avatarOwner
}
})
@@ -848,9 +940,11 @@ export const deleteMediaItem = async (url) => {
getSiteSettings(),
listAdminTags()
])
const items = await readMediaDirectory(uploadRoot)
const usage = [
...getDirectMediaUsage(url, posts, pages, siteSettings, tags),
...getPostThumbnailMediaUsage(url, buildPostThumbnailOwnerMap(await readMediaDirectory(uploadRoot)), posts, pages, siteSettings)
...getPostThumbnailMediaUsage(url, buildPostThumbnailOwnerMap(items), posts, pages, siteSettings),
...getPostBodyThumbnailMediaUsage(url, buildPostBodyThumbnailOwnerMap(items), posts, pages, siteSettings)
]
if (usage.length) {
@@ -878,10 +972,10 @@ export const deleteMediaItem = async (url) => {
* @returns {Promise<Object>} 변경된 미디어 항목
*/
export const renameMediaItem = async (url, name) => {
if (isPostCardThumbnailUrl(url)) {
if (isPostCardThumbnailUrl(url) || isPostBodyThumbnailUrl(url)) {
throw createError({
statusCode: 409,
message: '게시물 카드 썸네일은 원본 이미지와의 연결을 유지하기 위해 파일명을 변경할 수 없습니다.'
message: '게시물 자동 생성 이미지는 원본 이미지와의 연결을 유지하기 위해 파일명을 변경할 수 없습니다.'
})
}
@@ -891,9 +985,11 @@ export const renameMediaItem = async (url, name) => {
getSiteSettings(),
listAdminTags()
])
const items = await readMediaDirectory(uploadRoot)
const usage = [
...getDirectMediaUsage(url, posts, pages, siteSettings, tags),
...getPostThumbnailMediaUsage(url, buildPostThumbnailOwnerMap(await readMediaDirectory(uploadRoot)), posts, pages, siteSettings)
...getPostThumbnailMediaUsage(url, buildPostThumbnailOwnerMap(items), posts, pages, siteSettings),
...getPostBodyThumbnailMediaUsage(url, buildPostBodyThumbnailOwnerMap(items), posts, pages, siteSettings)
]
if (usage.length) {

View File

@@ -2,15 +2,22 @@ import { existsSync } from 'node:fs'
import { mkdir, stat } from 'node:fs/promises'
import { dirname, extname, join, posix } from 'node:path'
import sharp from 'sharp'
import { isPostCardThumbnailUrl } from '../../lib/post-card-thumbnail-url.js'
import {
getPostBodyThumbnailUrl,
isPostBodyThumbnailUrl,
isPostCardThumbnailUrl
} from '../../lib/post-card-thumbnail-url.js'
export { isPostCardThumbnailUrl }
export { getPostBodyThumbnailUrl, isPostBodyThumbnailUrl, isPostCardThumbnailUrl }
export const POST_THUMBNAIL_WIDTH = 640
export const POST_THUMBNAIL_HEIGHT = 360
export const POST_THUMBNAIL_QUALITY = 82
export const POST_BODY_IMAGE_WIDTH = 1600
export const POST_BODY_IMAGE_QUALITY = 84
const postUploadUrlPattern = /^\/uploads\/posts\/\d{4}\/\d{2}\/[^/?#]+$/i
const postUploadImageUrlPattern = /\/uploads\/posts\/\d{4}\/\d{2}\/[^/\s"'()<>\]]+\.(?:jpe?g|png|webp)/gi
const postThumbnailDirectoryName = 'thumbs'
const postThumbnailSuffix = '-card'
const postThumbnailExtension = '.webp'
@@ -92,6 +99,21 @@ export const getPostThumbnailDiskPath = (imageUrl) => {
return join(process.cwd(), 'public', decodeUrlPathPart(thumbnailUrl.replace(/^\/+/, '')))
}
/**
* 게시물 본문 표시용 이미지 URL의 디스크 경로를 조회한다.
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
* @returns {string} 본문 표시용 이미지 디스크 경로
*/
export const getPostBodyThumbnailDiskPath = (imageUrl) => {
const thumbnailUrl = getPostBodyThumbnailUrl(imageUrl)
if (!thumbnailUrl) {
return ''
}
return join(process.cwd(), 'public', decodeUrlPathPart(thumbnailUrl.replace(/^\/+/, '')))
}
/**
* 게시물 원본 이미지 URL의 디스크 경로를 조회한다.
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
@@ -138,6 +160,21 @@ export const getExistingPostThumbnailUrl = (imageUrl) => {
return existsSync(getPostThumbnailDiskPath(imageUrl)) ? thumbnailUrl : ''
}
/**
* 이미 생성된 게시물 본문 표시용 이미지 URL을 조회한다.
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
* @returns {string} 존재하는 본문 표시용 이미지 URL
*/
export const getExistingPostBodyThumbnailUrl = (imageUrl) => {
const thumbnailUrl = getPostBodyThumbnailUrl(imageUrl)
if (!thumbnailUrl) {
return ''
}
return existsSync(getPostBodyThumbnailDiskPath(imageUrl)) ? thumbnailUrl : ''
}
/**
* 게시물 대표 이미지의 카드 썸네일을 생성하거나 다시 생성한다.
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
@@ -174,3 +211,73 @@ export const createPostThumbnailForImageUrl = async (imageUrl) => {
return thumbnailUrl
}
/**
* 게시물 본문 이미지의 표시용 WebP 이미지를 생성하거나 다시 생성한다.
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
* @returns {Promise<string>} 생성된 본문 표시용 이미지 URL
*/
export const createPostBodyThumbnailForImageUrl = async (imageUrl) => {
const sourcePath = getPostImageDiskPath(imageUrl)
const thumbnailUrl = getPostBodyThumbnailUrl(imageUrl)
const thumbnailPath = getPostBodyThumbnailDiskPath(imageUrl)
if (!sourcePath || !thumbnailUrl || !thumbnailPath) {
return ''
}
try {
await stat(sourcePath)
} catch {
return ''
}
try {
await mkdir(dirname(thumbnailPath), { recursive: true })
await sharp(sourcePath)
.rotate()
.resize({
width: POST_BODY_IMAGE_WIDTH,
fit: 'inside',
withoutEnlargement: true
})
.webp({ quality: POST_BODY_IMAGE_QUALITY })
.toFile(thumbnailPath)
} catch {
return ''
}
return thumbnailUrl
}
/**
* 게시물 본문에서 업로드 이미지 URL을 추출한다.
* @param {string|null|undefined} content - 게시물 본문 마크다운
* @returns {Array<string>} 중복 제거된 이미지 URL 목록
*/
export const extractPostImageUrlsFromContent = (content) => {
const matches = String(content || '').match(postUploadImageUrlPattern) || []
return [...new Set(matches)]
}
/**
* 게시물 본문에 포함된 이미지의 표시용 WebP 이미지를 생성한다.
* @param {string|null|undefined} content - 게시물 본문 마크다운
* @returns {Promise<Array<string>>} 생성된 본문 표시용 이미지 URL 목록
*/
export const createPostBodyThumbnailsForContent = async (content) => {
const imageUrls = extractPostImageUrlsFromContent(content)
const generatedUrls = []
for (const imageUrl of imageUrls) {
const thumbnailUrl = await createPostBodyThumbnailForImageUrl(imageUrl)
if (thumbnailUrl) {
generatedUrls.push(thumbnailUrl)
}
}
return generatedUrls
}