이미지 파생 파일 생성 안정화

This commit is contained in:
2026-07-02 10:27:37 +09:00
parent 3a4e39332a
commit ffd6cbb822
12 changed files with 190 additions and 62 deletions

View File

@@ -25,7 +25,11 @@ import {
import { buildCalloutOpenerLine, CALLOUT_BACKGROUND_OPTIONS, QUOTE_BACKGROUND_OPTIONS } from '../../lib/markdown-callout.js'
import { buildCodeFenceOpener } from '../../lib/markdown-code-block.js'
import { buildToggleOpenerLine } from '../../lib/markdown-toggle.js'
import { isPostCardThumbnailUrl } from '../../lib/post-card-thumbnail-url.js'
import {
isPageCoverThumbnailUrl,
isPostBodyThumbnailUrl,
isPostCardThumbnailUrl
} from '../../lib/post-card-thumbnail-url.js'
import { getTextareaCaretCoordinates } from '../../lib/textarea-caret-coordinates.js'
import {
buildDefaultUploadSizeLimits,
@@ -1823,7 +1827,7 @@ const getMediaItemKind = (item) => {
* @returns {boolean} 선택 가능 여부
*/
const isMediaItemSelectableForTarget = (item) => {
if (isPostCardThumbnailUrl(item?.url)) {
if (isPostCardThumbnailUrl(item?.url) || isPostBodyThumbnailUrl(item?.url) || isPageCoverThumbnailUrl(item?.url)) {
return false
}

View File

@@ -5,7 +5,11 @@ import {
toAdminPostStoredTitle
} from '../../lib/admin-post-title.js'
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
import { isPostCardThumbnailUrl } from '../../lib/post-card-thumbnail-url.js'
import {
isPageCoverThumbnailUrl,
isPostBodyThumbnailUrl,
isPostCardThumbnailUrl
} from '../../lib/post-card-thumbnail-url.js'
import { DEFAULT_POST_TAG_LIMIT, normalizePostTagLimit } from '../../lib/post-tag-limit.js'
const props = defineProps({
@@ -94,12 +98,21 @@ const defaultTagColor = '#15171a'
/** @type {number} 한국어 본문 예상 읽기 속도(분당 공백 제외 문자 수) */
const READING_CHARS_PER_MINUTE = 600
/**
* 자동 생성 파생 이미지 URL인지 확인한다.
* @param {string|null|undefined} imageUrl - 검사할 이미지 URL
* @returns {boolean} 자동 생성 이미지이면 true
*/
const isGeneratedThumbnailUrl = (imageUrl) => isPostCardThumbnailUrl(imageUrl)
|| isPostBodyThumbnailUrl(imageUrl)
|| isPageCoverThumbnailUrl(imageUrl)
/**
* 대표 이미지로 선택 가능한 원본 이미지 목록
* @returns {Array<Object>} 카드 썸네일을 제외한 이미지 목록
* @returns {Array<Object>} 자동 생성 이미지를 제외한 이미지 목록
*/
const featuredImageMediaItems = computed(() => mediaItems.value.filter((item) => (
item?.kind === 'image' && !isPostCardThumbnailUrl(item.url)
item?.kind === 'image' && !isGeneratedThumbnailUrl(item.url)
)))
/**
@@ -776,7 +789,7 @@ const fetchMediaItems = async () => {
const openMediaPicker = async (target = 'featuredImage') => {
mediaPickerTarget.value = target
activeMediaPickerTab.value = 'upload'
selectedMediaPickerUrl.value = isPostCardThumbnailUrl(form[target]) ? '' : form[target] || ''
selectedMediaPickerUrl.value = isGeneratedThumbnailUrl(form[target]) ? '' : form[target] || ''
isMediaPickerOpen.value = true
await fetchMediaItems()
}
@@ -803,7 +816,7 @@ const selectPickedImage = (item) => {
* @returns {void}
*/
const applyPickedImage = () => {
if (!selectedMediaPickerUrl.value || isPostCardThumbnailUrl(selectedMediaPickerUrl.value)) {
if (!selectedMediaPickerUrl.value || isGeneratedThumbnailUrl(selectedMediaPickerUrl.value)) {
return
}

View File

@@ -1,5 +1,10 @@
# 업데이트 요약
## v1.10.13
- 글쓰기 이미지 선택창에 자동 생성된 본문·라이트박스·카드·Pages 커버 썸네일이 보이지 않도록 정리했다.
- 이미지 파생 파일 생성 실패 시 깨진 WebP 파일이 남지 않도록 검증 후 교체 방식으로 바꿨다.
## v1.10.12
- 홈 Pages 진열장 커버 이미지도 3:4 WebP 썸네일을 자동 생성해 원본 대신 우선 사용한다.

View File

@@ -1,5 +1,9 @@
# 의사결정 이력
## 2026-07-02 v1.10.13 — 글쓰기 미디어 선택은 원본만 허용한다
본문 표시용·라이트박스용·카드용·Pages 커버용 WebP는 공개 화면 최적화를 위한 시스템 파생 파일이다. 이 파일이 글쓰기 미디어 선택창에 노출되면 관리자가 원본 대신 파생 파일을 본문에 삽입할 수 있고, 파생 파일 생성이 실패한 경우 공개 화면도 깨진 URL을 직접 참조하게 된다. 따라서 글쓰기와 대표 이미지 선택창은 원본만 보여 주고, 서버 저장 검증도 자동 생성 이미지 URL을 대표/커버 이미지로 받지 않는다. 파생 파일은 임시 파일에 먼저 만들고 읽기 검증 후 교체해, 실패한 산출물이 남아 원본 fallback을 방해하지 않게 한다.
## 2026-07-02 v1.10.12 — Pages 커버는 별도 3:4 썸네일을 사용한다
홈 Pages 진열장은 섹션별로 여러 페이지 커버가 동시에 노출될 수 있어 원본 이미지를 그대로 사용하면 첫 화면 전송량이 커진다. 게시물 카드 썸네일은 16:9라 Pages 커버의 3:4 비율과 맞지 않으므로, 페이지 커버 전용 `thumbs/*-page-cover.webp` 파생 이미지를 450×600으로 생성한다. 원본은 관리자 다운로드와 보관용으로 유지하고, 공개 홈과 페이지 순서 모달은 생성된 커버 썸네일을 우선 사용한다. 자동 생성 파일은 관리자 미디어의 원본 라이브러리에서 분리해 실수로 미사용 삭제되는 상황을 막는다.

View File

@@ -131,9 +131,9 @@
| components/admin/AdminAdsSettingsCard.vue | 관리자 사이트 설정의 위치별 Ads 코드 카드(메인 피드·메인 인피드·오른쪽 사이드·게시물 오른쪽 사이드·게시물 본문 상단·인아티클·하단) |
| components/admin/AdminPostExportFileRow.vue | 관리자 사이트 설정 내보내기 작업의 분할 파일 선택 행 |
| components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·저장 중 추가 변경 재자동저장·발행 저장 중 이탈 확인·발행·예약·멤버십·비공개 상태 저장, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 오른쪽 하단 본문 통계(단어·문자·공백·읽기 시간·블록·이미지), 미리보기 emit·미저장 이탈 가드, 카드 썸네일을 숨기는 원본 전용 대표 이미지 선택, 대표 이미지 본문 상단 표시 토글, 추천 글 토글, 사이트 설정 기준 태그 최대 개수 제한, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천, 새 태그 입력명 유지와 `/`→`-` 슬러그 변환 |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·저장 중 추가 변경 재자동저장·발행 저장 중 이탈 확인·발행·예약·멤버십·비공개 상태 저장, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 오른쪽 하단 본문 통계(단어·문자·공백·읽기 시간·블록·이미지), 미리보기 emit·미저장 이탈 가드, 자동 생성 썸네일을 숨기는 원본 전용 대표 이미지 선택, 대표 이미지 본문 상단 표시 토글, 추천 글 토글, 사이트 설정 기준 태그 최대 개수 제한, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천, 새 태그 입력명 유지와 `/`→`-` 슬러그 변환 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 페이지 공개 상태 선택, HTML 문서 기본 모드, 홈 Pages 진열장 노출 메타와 등록된 페이지 섹션 검색형 선택, 빈 본문/`!`+Tab HTML 골격 자동 완성, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입, HTML 태그 내부 스마트 따옴표 정규화 |
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함)·미디어 모달(이미지·갤러리·비디오·오디오·파일, 업로드 탭 기본·첫 순서, 업로드 완료 파일 자동 선택, 카드 썸네일 파생 파일 숨김, 업로드 중 중복 업로드·닫기 차단·로딩 표시), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·선택 텍스트 색상 컨텍스트 `block-panel` emit, 에디터 본문 `Cmd/Ctrl+Z` 히스토리와 선택 위치 복원, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연 |
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함)·미디어 모달(이미지·갤러리·비디오·오디오·파일, 업로드 탭 기본·첫 순서, 업로드 완료 파일 자동 선택, 카드·본문·라이트박스·Pages 커버 자동 생성 이미지 숨김, 업로드 중 중복 업로드·닫기 차단·로딩 표시), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·선택 텍스트 색상 컨텍스트 `block-panel` emit, 에디터 본문 `Cmd/Ctrl+Z` 히스토리와 선택 위치 복원, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연 |
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(선택 텍스트 색상, 이미지·갤러리·링크·iframe·HTML·인용 배경색·콜아웃 제목·아이콘·배경색·코드·토글), 갤러리 선택 이미지 강조 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/HTML 블록, 콜아웃 Emoji on/off·이모지 프리셋·인용과 같은 배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상, 태그 헤더 이미지 업로드·삭제, 오버레이 제목·본문 편집, 수정 화면 변경 저장 버튼 변경사항 기준 활성화) |
@@ -287,7 +287,7 @@
| server/routes/admin/api/media.get.js | 관리자 미디어 목록 API |
| server/routes/admin/api/media.put.js | 관리자 미디어 파일명 변경 및 단일/복수 폴더 변경 API |
| server/routes/admin/api/media.delete.js | 관리자 미디어 삭제 API |
| server/routes/admin/api/media/thumbnail.post.js | 게시물 대표 이미지 원본의 목록 카드 썸네일 생성·재생성 API |
| server/routes/admin/api/media/thumbnail.post.js | 원본 이미지의 게시물 목록 카드·본문 표시·Pages 커버 썸네일 생성·재생성 API |
| server/routes/admin/api/media-folders.get.js | 관리자 미디어 폴더 목록 API |
| server/routes/admin/api/media-folders.post.js | 관리자 미디어 폴더 생성 API |
| server/routes/admin/api/uploads.post.js | 관리자 게시물·페이지용 미디어 원본 업로드 API(이미지·비디오·오디오·문서, 사이트 설정 1회 파일 수·총량 제한, Unicode 원본명 기반 파일명·충돌 시 넘버링, `/uploads/posts` 저장, 성공 시 `media_metadata`를 `미분류`로 기록) |
@@ -324,7 +324,7 @@
| server/utils/request-ip.js | 프록시 헤더 포함 요청 IP 조회 유틸리티 |
| server/utils/member-avatar.js | 회원 전용 썸네일 경로 검증·프로필 분리 시 `media_metadata`만 제거(디스크는 관리자 미디어에서 정리) |
| server/utils/member-avatar-upload.js | 회원 썸네일 공통 업로드 검증·Unicode 원본명 기반 파일명·WebP 변환·중앙 1:1 크롭·저장 유틸 |
| server/utils/admin-post-input.js | 관리자 게시물 입력값 Zod 검증 스키마, 카드 썸네일 URL의 대표 이미지 지정 차단 |
| server/utils/admin-post-input.js | 관리자 게시물 입력값 Zod 검증 스키마, 자동 생성 이미지 URL의 대표 이미지 지정 차단 |
| server/utils/admin-page-input.js | 관리자 고정 페이지 입력값 검증 스키마 |
| server/utils/admin-site-settings-input.js | 관리자 사이트 설정 입력값 검증 스키마 |
| server/utils/admin-navigation-input.js | 관리자 네비게이션 입력값 검증 스키마 |
@@ -332,7 +332,7 @@
| server/utils/site-settings.js | 사이트 설정 기본값 유틸리티 |
| server/utils/navigation-items.js | DB 없을 때 기본 네비 항목(UUID id·parentId·isFolder) |
| server/utils/navigation-tree.js | 네비 검증·삽입 순서·공개 primary 트리·DFS sort_order 재부여 |
| server/utils/media-library.js | 업로드 미디어·Unicode 파일명 정리·논리 폴더(`미분류`, 예약 `썸네일`)·`avatarOwner` 부착·아바타 삭제/이름변경 차단·게시물 카드 썸네일 사용처 연결 |
| server/utils/media-library.js | 업로드 미디어·Unicode 파일명 정리·논리 폴더(`미분류`, 예약 `썸네일`)·`avatarOwner` 부착·아바타 삭제/이름변경 차단·게시물 카드·본문·Pages 커버 썸네일 사용처 연결 |
| server/repositories/postgres-client.js | PostgreSQL 클라이언트 |
| server/repositories/content-repository.js | 콘텐츠 조회 저장소 |
| server/repositories/post-export-repository.js | 게시물 Export 작업·분할 파일 계획·ZIP 생성 워커 저장소 |

View File

@@ -109,6 +109,7 @@
- 카드 썸네일은 640×360 기준 `cover` 리사이즈와 WebP 품질 82를 사용한다. GIF·동영상·문서 파일은 썸네일을 자동 생성하지 않는다.
- 본문 표시용 이미지는 1600px `thumbs/*-body.webp`와 720px `thumbs/*-body-720.webp`를 함께 생성하고, 공개 본문 단일 이미지·갤러리는 `srcset`으로 화면 폭에 맞는 파일을 우선 사용한다.
- 라이트박스용 이미지는 2560px `thumbs/*-lightbox.webp`를 생성해 크게 보기 화면에서 우선 사용한다. 별도 원본 보기 UI는 제공하지 않으며, 파생 이미지 로드 실패 시에만 원본으로 fallback한다.
- 카드·본문·라이트박스·Pages 커버 파생 이미지는 임시 파일에 먼저 생성하고 Sharp 메타데이터로 읽기 검증한 뒤 최종 파일로 교체한다. 생성 또는 검증에 실패하면 깨진 파생 파일이 남지 않도록 대상 파일을 제거하고 공개 화면은 원본 fallback을 사용한다.
- 공개 게시물 응답은 원본 대표 이미지 `featuredImage`를 유지하고, 대응 썸네일 파일이 실제로 존재할 때만 `featuredImageThumbnail`을 추가한다.
- 기존 대표 이미지는 로컬에서 `npm run images:backfill-post-thumbnails`, NAS 운영에서 `sh scripts/backfill-post-thumbnails-production.sh`로 게시물 대표 이미지 URL만 스캔해 누락된 카드 썸네일을 생성한다. 관리자 미디어의 원본 이미지 상세에서도 카드 썸네일을 개별 생성·재생성할 수 있다.
- 기존 본문 이미지는 로컬에서 `npm run images:backfill-post-body-thumbnails`, NAS 운영에서 `sh scripts/backfill-post-body-thumbnails-production.sh`로 전체 게시물 본문 URL을 스캔해 누락된 본문 표시용 WebP, 작은 본문 표시용 WebP, 라이트박스용 WebP를 생성한다.
@@ -726,7 +727,7 @@ components/content/
- 인라인 마크다운은 Obsidian식 `$...$` 첨자 토큰과 `::color[텍스트]` 색상 토큰을 지원한다. `$H_2O$`는 `H`+아래첨자 `2`+`O`, `$2^8$`은 `2`+위첨자 `8`, `$_B^AR$`는 아래첨자 `B`와 위첨자 `AR`로 렌더링한다. 첨자 본문에 공백·기호가 필요하면 `$_{...}$`, `$^{...}$` 형식도 허용한다. 색상 토큰은 `::gray[덜 중요한 말]`, `::red[**중요한 말**]`처럼 저장하며 `gray`, `blue`, `green`, `yellow`, `red`, `purple`만 허용한다. 색상 토큰 내부의 굵게·기울임·인라인 코드·링크·첨자는 재귀적으로 렌더링한다.
- 라이브 모드 `:::` fenced 블록의 원본 범위는 여는 줄부터 닫는 `:::` 줄까지만 포함한다. 연속된 콜아웃·토글·갤러리 등은 앞 블록 편집 시 다음 블록의 선언 줄을 교체 범위에 포함하지 않는다.
- 이미지 파일을 붙여넣거나 드롭하면 관리자 업로드 API로 저장한 뒤 현재 커서 위치에 이미지 또는 갤러리 마크다운을 삽입한다.
- 툴바 `이미지`·`갤러리`는 미디어 모달을 연다. 모달 기본 탭은 **미디어 라이브러리**이며 **업로드** 탭에서 드래그·파일 선택 후 라이브러리 목록을 갱신한다. 업로드 중에는 추가 드롭·파일 선택과 모달 닫기를 막고 로딩 스피너를 표시하며, 업로드 완료 후에는 모달을 자동으로 닫거나 본문에 자동 삽입하지 않는다. 본문 삽입은 라이브러리 목록에서 파일을 직접 선택한 뒤 `삽입` 버튼을 눌렀을 때만 실행한다.
- 툴바 `이미지`·`갤러리`는 미디어 모달을 연다. 모달 기본 탭은 **미디어 라이브러리**이며 **업로드** 탭에서 드래그·파일 선택 후 라이브러리 목록을 갱신한다. 업로드 중에는 추가 드롭·파일 선택과 모달 닫기를 막고 로딩 스피너를 표시하며, 업로드 완료 후에는 모달을 자동으로 닫거나 본문에 자동 삽입하지 않는다. 본문 삽입은 라이브러리 목록에서 파일을 직접 선택한 뒤 `삽입` 버튼을 눌렀을 때만 실행한다. 미디어 선택 목록에는 원본 파일만 표시하고, 게시물 카드·본문 1600px·본문 720px·라이트박스·Pages 커버 자동 생성 이미지는 숨긴다.
- 미디어 라이브러리에서 단일 이미지를 선택하면 `![alt](url)` 형식으로 삽입한다.
- 미디어 라이브러리에서 여러 이미지를 선택하면 `:::gallery` fenced block으로 삽입한다.
- 작성 모드에서 커서가 이미지 마크다운 줄, `:::gallery`, `:::link`, `:::iframe`, `:::html`, 인용문, `:::callout`, 코드 fenced 블록, `:::toggle` 블록 안에 있고 textarea(또는 블록 패널)에 포커스가 있으면 게시물 설정 사이드바(420px) 위에 **블록 설정 패널**(`AdminEditorBlockPanel`)이 오른쪽에서 슬라이드 인한다. 본문·패널 바깥을 클릭하면 슬라이드 아웃한다. 라이브 모드 멀티라인 편집기는 실제 커서가 있는 원본 줄을 패널 상태에 반영하며, 커서가 지원 블록 밖으로 이동하면 직전 코드·콜아웃·인용 설정 패널을 닫는다. 코드 fenced 블록 판별은 위에서 아래로 여는 펜스와 닫는 펜스를 짝지어 처리해, 닫는 ` ``` ` 줄을 다음 코드 블록 시작으로 오인하지 않는다. 갤러리 이미지 추가 미디어 모달을 여는 동안에는 활성 갤러리 컨텍스트와 패널 상태를 유지한다.

View File

@@ -1,5 +1,12 @@
# 업데이트 이력
## v1.10.13
- 글쓰기 이미지·갤러리 선택 모달에서 게시물 카드·본문 1600px·본문 720px·라이트박스·Pages 커버 자동 생성 이미지를 숨기고 원본만 선택하도록 수정.
- 대표 이미지 선택 모달과 게시물·페이지 저장 API에서도 자동 생성 이미지 URL 저장을 차단하도록 보강.
- 이미지 파생 파일 생성 시 임시 파일에 먼저 쓰고 Sharp로 읽기 검증 후 최종 파일로 교체하도록 수정.
- 파생 이미지 생성 실패 시 깨진 산출물이 남지 않도록 임시 파일과 대상 파일을 정리하도록 수정.
## v1.10.12
- 홈 Pages 진열장 커버 이미지용 3:4 WebP 썸네일(`thumbs/*-page-cover.webp`, 450×600) 생성 추가.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.10.12",
"version": "1.10.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.10.12",
"version": "1.10.13",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

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

View File

@@ -1,14 +1,32 @@
import { z } from 'zod'
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
import {
isPageCoverThumbnailUrl,
isPostBodyThumbnailUrl,
isPostCardThumbnailUrl
} from '../../lib/post-card-thumbnail-url.js'
import { pageStatusSchema } from './content-schema.js'
/**
* 자동 생성 파생 이미지 URL인지 확인한다.
* @param {string|null|undefined} imageUrl - 검사할 이미지 URL
* @returns {boolean} 자동 생성 이미지이면 true
*/
const isGeneratedThumbnailUrl = (imageUrl) => isPostCardThumbnailUrl(imageUrl)
|| isPostBodyThumbnailUrl(imageUrl)
|| isPageCoverThumbnailUrl(imageUrl)
export const adminPageInputSchema = z.object({
title: z.string().trim().min(1),
slug: z.string().trim().min(1).regex(/^[a-z0-9가-힣]+(?:[-_][a-z0-9가-힣]+)*$/),
renderMode: z.enum(['markdown', 'html_document']).default('html_document'),
status: pageStatusSchema.default('published'),
content: z.string().default(''),
featuredImage: z.string().trim().nullable().default(null),
featuredImage: z.string()
.trim()
.nullable()
.default(null)
.refine((value) => !isGeneratedThumbnailUrl(value), '자동 생성 이미지는 페이지 커버로 사용할 수 없습니다.'),
isListed: z.boolean().optional().default(false),
isFeatured: z.boolean().optional().default(false),
listingSection: z.string().trim().max(80).optional().default(''),

View File

@@ -1,9 +1,22 @@
import { z } from 'zod'
import { ADMIN_POST_PLACEHOLDER_TITLE } from '../../lib/admin-post-title.js'
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
import { isPostCardThumbnailUrl } from '../../lib/post-card-thumbnail-url.js'
import {
isPageCoverThumbnailUrl,
isPostBodyThumbnailUrl,
isPostCardThumbnailUrl
} from '../../lib/post-card-thumbnail-url.js'
import { postStatusSchema } from './content-schema.js'
/**
* 자동 생성 파생 이미지 URL인지 확인한다.
* @param {string|null|undefined} imageUrl - 검사할 이미지 URL
* @returns {boolean} 자동 생성 이미지이면 true
*/
const isGeneratedThumbnailUrl = (imageUrl) => isPostCardThumbnailUrl(imageUrl)
|| isPostBodyThumbnailUrl(imageUrl)
|| isPageCoverThumbnailUrl(imageUrl)
export const adminPostInputSchema = z.object({
title: z.preprocess((val) => {
const t = String(val ?? '').trim()
@@ -16,7 +29,7 @@ export const adminPostInputSchema = z.object({
.trim()
.nullable()
.default(null)
.refine((value) => !isPostCardThumbnailUrl(value), '카드 썸네일은 대표 이미지로 사용할 수 없습니다.'),
.refine((value) => !isGeneratedThumbnailUrl(value), '자동 생성 이미지는 대표 이미지로 사용할 수 없습니다.'),
showFeaturedImage: z.boolean().default(false),
isFeatured: z.boolean().default(false),
seoTitle: z.string().trim().default(''),

View File

@@ -1,5 +1,5 @@
import { existsSync } from 'node:fs'
import { mkdir, stat } from 'node:fs/promises'
import { mkdir, rename, rm, stat } from 'node:fs/promises'
import { dirname, extname, join, posix } from 'node:path'
import sharp from 'sharp'
import {
@@ -41,6 +41,72 @@ const postThumbnailExtension = '.webp'
const supportedPostThumbnailExtensions = new Set(['.jpg', '.jpeg', '.png', '.webp'])
const supportedPostThumbnailTypes = new Set(['image/jpeg', 'image/png', 'image/webp'])
/**
* 임시 이미지 출력 경로를 만든다.
* @param {string} filePath - 최종 저장 경로
* @returns {string} 임시 저장 경로
*/
const createTemporaryImagePath = (filePath) => `${filePath}.${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`
/**
* 파일이 있으면 조용히 제거한다.
* @param {string} filePath - 제거할 파일 경로
* @returns {Promise<void>} 제거 결과
*/
const removeImageFileIfExists = async (filePath) => {
if (!filePath) {
return
}
await rm(filePath, { force: true }).catch(() => {})
}
/**
* 생성된 이미지 파일이 실제로 읽히는지 확인한다.
* @param {string} filePath - 검사할 이미지 파일 경로
* @returns {Promise<boolean>} 유효한 이미지이면 true
*/
const isGeneratedImageReadable = async (filePath) => {
const fileStat = await stat(filePath).catch(() => null)
if (!fileStat?.isFile() || fileStat.size <= 0) {
return false
}
try {
await sharp(filePath).metadata()
return true
} catch {
return false
}
}
/**
* 임시 파일로 이미지를 만든 뒤 검증이 끝나면 최종 파일로 교체한다.
* @param {string} targetPath - 최종 저장 경로
* @param {(temporaryPath: string) => Promise<void>} writeTemporaryImage - 임시 이미지 생성 함수
* @returns {Promise<boolean>} 생성 성공 여부
*/
const writeVerifiedDerivativeImage = async (targetPath, writeTemporaryImage) => {
const temporaryPath = createTemporaryImagePath(targetPath)
try {
await mkdir(dirname(targetPath), { recursive: true })
await writeTemporaryImage(temporaryPath)
if (!await isGeneratedImageReadable(temporaryPath)) {
throw new Error('INVALID_DERIVATIVE_IMAGE')
}
await rename(temporaryPath, targetPath)
return true
} catch {
await removeImageFileIfExists(temporaryPath)
await removeImageFileIfExists(targetPath)
return false
}
}
/**
* URL 경로 조각을 파일 시스템 경로용 문자열로 디코딩한다.
* @param {string} value - URL 경로 조각
@@ -302,9 +368,7 @@ export const createPostThumbnailForImageUrl = async (imageUrl) => {
return ''
}
await mkdir(dirname(thumbnailPath), { recursive: true })
await sharp(sourcePath)
const created = await writeVerifiedDerivativeImage(thumbnailPath, (temporaryPath) => sharp(sourcePath)
.rotate()
.resize({
width: POST_THUMBNAIL_WIDTH,
@@ -314,9 +378,9 @@ export const createPostThumbnailForImageUrl = async (imageUrl) => {
withoutEnlargement: true
})
.webp({ quality: POST_THUMBNAIL_QUALITY })
.toFile(thumbnailPath)
.toFile(temporaryPath))
return thumbnailUrl
return created ? thumbnailUrl : ''
}
/**
@@ -339,9 +403,7 @@ export const createPageCoverThumbnailForImageUrl = async (imageUrl) => {
return ''
}
await mkdir(dirname(thumbnailPath), { recursive: true })
await sharp(sourcePath)
const created = await writeVerifiedDerivativeImage(thumbnailPath, (temporaryPath) => sharp(sourcePath)
.rotate()
.resize({
width: PAGE_COVER_THUMBNAIL_WIDTH,
@@ -351,9 +413,9 @@ export const createPageCoverThumbnailForImageUrl = async (imageUrl) => {
withoutEnlargement: true
})
.webp({ quality: PAGE_COVER_THUMBNAIL_QUALITY })
.toFile(thumbnailPath)
.toFile(temporaryPath))
return thumbnailUrl
return created ? thumbnailUrl : ''
}
/**
@@ -378,39 +440,40 @@ export const createPostBodyThumbnailForImageUrl = async (imageUrl) => {
return ''
}
try {
await mkdir(dirname(thumbnailPath), { recursive: true })
const createdBody = await writeVerifiedDerivativeImage(thumbnailPath, (temporaryPath) => sharp(sourcePath)
.rotate()
.resize({
width: POST_BODY_IMAGE_WIDTH,
fit: 'inside',
withoutEnlargement: true
})
.webp({ quality: POST_BODY_IMAGE_QUALITY })
.toFile(temporaryPath))
const createdPreview = await writeVerifiedDerivativeImage(previewThumbnailPath, (temporaryPath) => sharp(sourcePath)
.rotate()
.resize({
width: POST_BODY_PREVIEW_IMAGE_WIDTH,
fit: 'inside',
withoutEnlargement: true
})
.webp({ quality: POST_BODY_IMAGE_QUALITY })
.toFile(temporaryPath))
const createdLightbox = await writeVerifiedDerivativeImage(lightboxThumbnailPath, (temporaryPath) => sharp(sourcePath)
.rotate()
.resize({
width: POST_LIGHTBOX_IMAGE_WIDTH,
fit: 'inside',
withoutEnlargement: true
})
.webp({ quality: POST_BODY_IMAGE_QUALITY })
.toFile(temporaryPath))
await sharp(sourcePath)
.rotate()
.resize({
width: POST_BODY_IMAGE_WIDTH,
fit: 'inside',
withoutEnlargement: true
})
.webp({ quality: POST_BODY_IMAGE_QUALITY })
.toFile(thumbnailPath)
await sharp(sourcePath)
.rotate()
.resize({
width: POST_BODY_PREVIEW_IMAGE_WIDTH,
fit: 'inside',
withoutEnlargement: true
})
.webp({ quality: POST_BODY_IMAGE_QUALITY })
.toFile(previewThumbnailPath)
await sharp(sourcePath)
.rotate()
.resize({
width: POST_LIGHTBOX_IMAGE_WIDTH,
fit: 'inside',
withoutEnlargement: true
})
.webp({ quality: POST_BODY_IMAGE_QUALITY })
.toFile(lightboxThumbnailPath)
} catch {
if (!createdBody || !createdPreview || !createdLightbox) {
await Promise.all([
removeImageFileIfExists(thumbnailPath),
removeImageFileIfExists(previewThumbnailPath),
removeImageFileIfExists(lightboxThumbnailPath)
])
return ''
}