본문 이미지 최적화·라이트박스·번호형 페이지네이션 반영 (v1.10.3)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user