본문 이미지 최적화·라이트박스·번호형 페이지네이션 반영 (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

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