diff --git a/Dockerfile b/Dockerfile index 40dbb39..91648bc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,10 @@ ENV HOST=0.0.0.0 ENV PORT=3000 COPY --from=builder /app/.output ./.output +COPY --from=builder /app/scripts/backfill-post-thumbnails.js ./.output/server/scripts/backfill-post-thumbnails.js +COPY --from=builder /app/scripts/backfill-post-body-thumbnails.js ./.output/server/scripts/backfill-post-body-thumbnails.js +COPY --from=builder /app/server/utils/post-thumbnail-image.js ./.output/server/server/utils/post-thumbnail-image.js +COPY --from=builder /app/lib/post-card-thumbnail-url.js ./.output/server/lib/post-card-thumbnail-url.js EXPOSE 3000 diff --git a/components/content/ContentMarkdownRenderer.vue b/components/content/ContentMarkdownRenderer.vue index c707f6c..9f559bd 100644 --- a/components/content/ContentMarkdownRenderer.vue +++ b/components/content/ContentMarkdownRenderer.vue @@ -5,7 +5,7 @@ import { isImageUrl, parseImageMarkdownLine } from '../../lib/markdown-image.js' -import { getPostBodyThumbnailUrl } from '../../lib/post-card-thumbnail-url.js' +import { getPostBodyPreviewThumbnailUrl, getPostBodyThumbnailUrl } from '../../lib/post-card-thumbnail-url.js' import { readEditableTextFromElement, setEditableCaretOffset } from '../../lib/markdown-inline.js' import { applyLiveSelectionDelete, @@ -1057,6 +1057,32 @@ const contentLightboxImages = computed(() => { */ const getContentDisplayImageUrl = (imageUrl) => getPostBodyThumbnailUrl(imageUrl) || imageUrl || '' +/** + * 게시물 본문 이미지의 반응형 srcset을 만든다. + * @param {string|null|undefined} imageUrl - 원본 이미지 URL + * @returns {string} srcset 문자열 + */ +const getContentDisplayImageSrcset = (imageUrl) => { + const previewUrl = getPostBodyPreviewThumbnailUrl(imageUrl) + const bodyUrl = getPostBodyThumbnailUrl(imageUrl) + + return [ + previewUrl ? `${previewUrl} 720w` : '', + bodyUrl ? `${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} images - 갤러리 이미지 목록 @@ -1118,6 +1144,8 @@ const onGalleryImageError = (event, fallbackUrl) => { return } + image.removeAttribute('srcset') + image.removeAttribute('sizes') image.src = fallbackUrl } @@ -3233,6 +3261,7 @@ onBeforeUnmount(() => { { { String(currentSrc.value || '').trim().length > 0) +const currentSrcset = computed(() => currentSrc.value === props.src ? props.srcset : '') const errorLabel = computed(() => { const trimmed = String(props.fallbackSrc || props.src || '').trim() @@ -111,6 +120,8 @@ const activateImage = () => { v-if="hasRenderableSrc && !loadFailed" class="prose-image__media w-full object-cover" :src="currentSrc" + :srcset="currentSrcset || undefined" + :sizes="currentSrcset ? sizes : undefined" :alt="alt" loading="lazy" decoding="async" diff --git a/docs/changelog.md b/docs/changelog.md index fda679a..4ead208 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,17 @@ # 업데이트 요약 +## v1.10.7 + +- 게시물 본문 이미지에 720px 표시용 WebP를 추가하고 공개 본문에서 화면 폭에 맞는 이미지를 고르도록 `srcset`을 적용했다. + +## v1.10.6 + +- NAS 호스트에 npm이 없어도 컨테이너 안에서 이미지 백필을 실행할 수 있게 보정했다. + +## v1.10.5 + +- 기존 게시물 본문 이미지도 한 번에 본문 표시용 WebP 이미지로 생성할 수 있는 백필 명령을 추가했다. + ## v1.10.4 - 공개 Markdown 본문 블록 사이에 보이던 Vue fragment 주석을 줄이도록 렌더링 구조를 정리했다. diff --git a/docs/deploy.md b/docs/deploy.md index 9f450f1..48517b2 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -1,6 +1,6 @@ # 배포 가이드 -> 로컬 기준 v1.10.1에서 `npm run verify` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. +> 로컬 기준 v1.10.7에서 `npm run verify` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. ## 빌드 유형 @@ -402,10 +402,13 @@ docker compose --env-file .env.production restart sori-studio-db | 명령 | 용도 | |------|------| -| `npm run images:backfill-post-thumbnails` | 게시물 대표 이미지용 카드 썸네일(`thumbs/*-card.webp`)이 누락된 기존 업로드를 일괄 생성 | +| `npm run images:backfill-post-thumbnails` | 로컬 Node/npm 환경에서 게시물 대표 이미지용 카드 썸네일(`thumbs/*-card.webp`) 일괄 생성 | +| `npm run images:backfill-post-body-thumbnails` | 로컬 Node/npm 환경에서 게시물 본문 표시용 WebP 이미지(`thumbs/*-body.webp`, `thumbs/*-body-720.webp`) 일괄 생성 | +| `sh scripts/backfill-post-thumbnails-production.sh` | npm 없는 NAS 호스트에서 앱 컨테이너 내부 Node로 카드 썸네일 일괄 생성 | +| `sh scripts/backfill-post-body-thumbnails-production.sh` | npm 없는 NAS 호스트에서 앱 컨테이너 내부 Node로 본문 표시용 WebP 이미지 일괄 생성 | | `npm run navigation:refresh-recommended-favicons` | 추천 사이트 파비콘 캐시를 수동으로 전체 갱신 | -본문 표시용 WebP 이미지(`thumbs/*-body.webp`)는 게시물 저장 시 생성된다. 기존 게시물의 과거 이미지는 다시 저장하거나 관리자 미디어 상세에서 개별 생성한다. +본문 표시용 WebP 이미지(`thumbs/*-body.webp`, `thumbs/*-body-720.webp`)는 게시물 저장 시 생성된다. 기존 게시물의 과거 이미지는 로컬에서는 `npm run images:backfill-post-body-thumbnails`, NAS 운영에서는 `sh scripts/backfill-post-body-thumbnails-production.sh`로 일괄 생성할 수 있다. production 래퍼는 `docker compose up -d sori-studio` 후 실행 중인 앱 컨테이너 안의 Node로 백필 스크립트를 실행하므로 NAS 호스트에 npm이 없어도 된다. Homepage 위젯 연동은 `docs/homepage-widget.md`를 참고한다. Docker에서 Homepage가 같은 네트워크에 있으면 `http://sori-studio:3000/api/homepage-widget`을 사용한다. diff --git a/docs/history.md b/docs/history.md index 55ab18e..7f695f7 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,17 @@ # 의사결정 이력 +## 2026-07-01 v1.10.7 — 본문 이미지는 720px와 1600px를 함께 제공한다 + +본문 이미지가 모두 1600px 표시용 WebP만 사용하면 모바일 화면이나 갤러리 작은 셀에서도 필요 이상의 이미지를 내려받을 수 있다. 원본은 라이트박스용으로 유지하고, 공개 본문 표시에는 720px와 1600px WebP를 `srcset`으로 함께 제공해 브라우저가 화면 폭에 맞는 파일을 고르게 한다. 기존 게시물은 백필 명령이 두 파생 이미지의 누락 여부를 함께 확인하도록 정리한다. + +## 2026-07-01 v1.10.6 — 운영 백필은 NAS 호스트 npm에 의존하지 않는다 + +NAS 배포는 Docker 이미지 안에서 Node 런타임을 실행하므로 호스트에 npm이 없을 수 있다. 이미지 백필 역시 호스트 npm 명령이 아니라 `sh` 래퍼가 실행 중인 앱 컨테이너의 Node로 처리해야 운영 절차와 맞다. 또한 Nuxt 빌드 산출물에는 source repository 파일이 그대로 포함되지 않으므로, 백필 스크립트는 DB를 직접 조회하고 운영 이미지의 `.output/server` 아래에 필요한 최소 스크립트와 이미지 유틸만 포함하는 방식으로 정리한다. + +## 2026-07-01 v1.10.5 — 기존 본문 이미지는 백필 명령으로 일괄 전환한다 + +본문 표시용 WebP 이미지는 새로 저장되는 게시물에는 자동 생성되지만, 운영 DB에 이미 있는 게시물까지 관리자 화면에서 개별 처리하는 것은 글 수가 늘수록 비현실적이다. 따라서 기존 게시물 본문을 스캔해 누락된 `thumbs/*-body.webp`만 생성하는 백필 명령을 별도로 두고, 운영에서는 배포 후 필요할 때 한 번 실행하는 방식으로 정리한다. + ## 2026-07-01 v1.10.3 — 공개 목록 페이지네이션은 직접 이동을 기본으로 한다 이전/다음만 있는 페이지네이션은 글이 수십 개씩 추가되는 기간에 원하는 구간으로 이동하기 어렵다. 공개 게시물 목록과 태그 목록은 서버 페이지네이션을 이미 사용하므로, UI는 처음·끝 페이지와 현재 위치 주변 번호를 보여 주고 중간 구간은 `...`으로 접는 방식으로 바꾼다. 모바일에서는 번호가 많을 때 한 줄 가로 스크롤을 허용해 레이아웃 깨짐을 피한다. diff --git a/docs/map.md b/docs/map.md index 5211cbf..cfb21da 100644 --- a/docs/map.md +++ b/docs/map.md @@ -65,6 +65,9 @@ |------|------| | scripts/check-js-syntax.js | `npm run lint`에서 JS/MJS/CJS 파일을 `node --check`로 문법 점검 | | scripts/backfill-post-thumbnails.js | 게시물 대표 이미지 URL 기준 목록 카드용 WebP 썸네일 백필 | +| scripts/backfill-post-body-thumbnails.js | 게시물 본문 이미지 URL 기준 본문 표시용 WebP 썸네일 백필 | +| scripts/backfill-post-thumbnails-production.sh | npm 없는 NAS 호스트에서 앱 컨테이너 내부 Node로 카드 썸네일 백필 실행 | +| scripts/backfill-post-body-thumbnails-production.sh | npm 없는 NAS 호스트에서 앱 컨테이너 내부 Node로 본문 표시용 WebP 백필 실행 | ## DB 마이그레이션 @@ -91,7 +94,7 @@ | server/routes/feed.xml.get.js | 공개 RSS 2.0 피드 별칭(`/feed.xml`) | | server/routes/rss.get.js | 공개 RSS 2.0 피드 별칭(`/rss`) | | server/plugins/site-custom-code.js | 공개 Nuxt HTML 응답에 사이트 설정 헤더·푸터 코드 삽입(`/admin`, `/api`, `/uploads`, `/_nuxt`, `/ads.txt` 제외) | -| server/utils/post-thumbnail-image.js | 게시물 업로드 이미지의 카드 썸네일·본문 표시용 WebP 이미지 URL·디스크 경로·생성 가능 여부·본문 이미지 추출 규칙 | +| server/utils/post-thumbnail-image.js | 게시물 업로드 이미지의 카드 썸네일·본문 표시용 1600px/720px WebP 이미지 URL·디스크 경로·생성 가능 여부·본문 이미지 추출 규칙 | | server/utils/sitemap.js | 공개 sitemap XML·robots.txt 생성 | | server/utils/rss-feed.js | 공개 발행글 기반 RSS 2.0 XML 생성, 게시물 이미지 Media RSS 썸네일 출력 | @@ -148,7 +151,7 @@ | 파일 | 화면 위치 | |------|-----------| | components/content/ContentRenderer.vue | 게시물/페이지 본문 | -| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `
` 처리, 표준 마크다운 표 파싱·렌더링(셀 내부 인라인 링크와 `
` 줄바꿈 포함), 공개 게시물 인아티클 광고를 본문 길이에 따라 0~2회 삽입, Obsidian식 위첨자·아래첨자 인라인 렌더링, 확장 블록 파싱, `:::` fenced 블록 원본 범위 보정, 닫히지 않은 코드 펜스 하위 콘텐츠 보호, 인용 막대 색상 옵션(`> [!bg=...]`), 라이브 문단 `>` 즉시 인용 변환과 ` ``` `·`!!!` Enter 코드 블록·콜아웃 단축 생성, 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 인용·콜아웃 멀티라인 편집 줄 범위 포커스와 위/아래 방향키 외부 문단 이탈, 인용 Backspace 문단 복귀, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 본문 표시용 WebP 이미지 우선 표시와 원본 fallback, 단일 이미지·갤러리 통합 라이트박스, 갤러리 비율 기반 행 레이아웃, 라이브 이미지·갤러리 hover 삭제 액션, 리스트 마커 파란 계열 통일과 여러 줄 항목 첫 줄 기준 정렬 | +| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `
` 처리, 표준 마크다운 표 파싱·렌더링(셀 내부 인라인 링크와 `
` 줄바꿈 포함), 공개 게시물 인아티클 광고를 본문 길이에 따라 0~2회 삽입, Obsidian식 위첨자·아래첨자 인라인 렌더링, 확장 블록 파싱, `:::` fenced 블록 원본 범위 보정, 닫히지 않은 코드 펜스 하위 콘텐츠 보호, 인용 막대 색상 옵션(`> [!bg=...]`), 라이브 문단 `>` 즉시 인용 변환과 ` ``` `·`!!!` Enter 코드 블록·콜아웃 단축 생성, 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 인용·콜아웃 멀티라인 편집 줄 범위 포커스와 위/아래 방향키 외부 문단 이탈, 인용 Backspace 문단 복귀, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 본문 표시용 720w/1600w WebP `srcset` 우선 표시와 원본 fallback, 단일 이미지·갤러리 통합 라이트박스, 갤러리 비율 기반 행 레이아웃, 라이브 이미지·갤러리 hover 삭제 액션, 리스트 마커 파란 계열 통일과 여러 줄 항목 첫 줄 기준 정렬 | | components/content/ContentMarkdownEditableInline.vue | 라이브 모드 공통 인라인 편집 영역, 일반 인라인 마크다운·Obsidian식 첨자 렌더링과 plain text 멀티라인 본문·끝 줄바꿈 보존, 한글 IME 조합 확정 Enter의 블록별 동작 연결, 위/아래 인접 블록 이동 시 편집 스크롤 추적, Shift 위/아래 인접 블록 선택 확장·단계적 `Cmd/Ctrl+A` 처리, 멀티라인 Enter 텍스트 값 치환, 첫 줄 빈 줄 포함 줄바꿈 유지 | | components/content/ContentMarkdownInlineSegments.vue | 공개·라이브 본문 공통 인라인 세그먼트 렌더링(링크·굵게·기울임·인라인 코드·첨자·인라인 색상), 링크·색상 내부 중첩 인라인 렌더링, 사용자 화면 fragment 주석 반복을 줄이는 단일 루트 구조 | | components/content/ContentMarkdownBlockGroup.vue | Markdown 본문 블록 단위 렌더 그룹, 라이브 삽입 영역을 조건부로만 DOM에 추가해 공개 화면 fragment 주석 감소 | @@ -409,5 +412,5 @@ | scripts/migrate-development-db.js | 기존 로컬 개발 DB 마이그레이션 명령 호환 래퍼 | | scripts/migrate-production-db.sh | npm 없는 NAS 호스트용 운영 DB 마이그레이션 적용/상태/baseline 실행 | | .env.example | 환경 변수 예시 | -| Dockerfile | NAS 운영 이미지 빌드 | +| Dockerfile | NAS 운영 이미지 빌드, `.output/server` 기반 앱 런타임과 이미지 백필 스크립트 포함 | | docker-compose.yml | NAS 컨테이너 실행 초안 | diff --git a/docs/spec.md b/docs/spec.md index 4bcd10e..ad9e16f 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -107,8 +107,10 @@ - 관리자 미디어 업로드 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.webp`와 720px `thumbs/*-body-720.webp`를 함께 생성하고, 공개 본문 단일 이미지·갤러리는 `srcset`으로 화면 폭에 맞는 파일을 우선 사용한다. - 공개 게시물 응답은 원본 대표 이미지 `featuredImage`를 유지하고, 대응 썸네일 파일이 실제로 존재할 때만 `featuredImageThumbnail`을 추가한다. -- 기존 대표 이미지는 `npm run images:backfill-post-thumbnails`로 게시물 대표 이미지 URL만 스캔해 누락된 카드 썸네일을 생성한다. 관리자 미디어의 원본 이미지 상세에서도 카드 썸네일을 개별 생성·재생성할 수 있다. +- 기존 대표 이미지는 로컬에서 `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를 생성한다. ### 공개 목록·상세의 발행일 표시 diff --git a/docs/todo.md b/docs/todo.md index 46ad43e..045771f 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -9,9 +9,7 @@ ## 프론트엔드 개발 - 공개 게시물 본문 이미지 최적화 후속 진행. - - 현재 게시물 저장 시 본문 표시용 WebP(`thumbs/*-body.webp`, 1600px)가 생성되고 공개 본문에서 우선 표시됨. - - 기존 업로드 이미지 전체에 대한 백필 스크립트가 아직 없으므로 과거 게시물은 다시 저장하거나 미디어 상세에서 개별 생성해야 함. - - 화면 크기별 `picture`/`srcset`과 갤러리 미리보기용 더 작은 파생 이미지(예: 720px)는 아직 미구현. + - 현재 게시물 저장 시 본문 표시용 WebP(`thumbs/*-body.webp`, 1600px)와 작은 본문 표시용 WebP(`thumbs/*-body-720.webp`, 720px)가 생성되고 공개 본문에서 `srcset`으로 우선 표시됨. - 라이트박스는 현재 원본 URL을 사용하므로, 초고용량 원본이 많은 경우 큰 파생 이미지와 원본 보기 옵션 분리 검토. - 업로드 총량 제한 및 메모리 사용량 개선. - 현재 관리자 업로드는 파일별 제한만 있고 한 번에 올리는 파일 개수·총 용량 제한이 없음. diff --git a/docs/update.md b/docs/update.md index eca34c5..0423b05 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,26 @@ # 업데이트 이력 +## v1.10.7 + +- 게시물 본문 이미지 저장 시 720px 작은 본문 표시용 WebP(`thumbs/*-body-720.webp`) 생성 추가. +- 공개 본문 단일 이미지와 갤러리 이미지에 720w/1600w `srcset` 적용. +- 기존 본문 이미지 백필이 1600px·720px 파생 이미지를 모두 확인하고 누락분을 생성하도록 수정. +- 본문 이미지 최적화 후속 과제에서 `srcset`·720px 미구현 항목 제거. + +## v1.10.6 + +- NAS 호스트에 npm이 없는 환경에서도 이미지 백필을 실행할 수 있도록 production shell 래퍼 추가. +- 운영 Docker 이미지의 `.output/server` 아래에 이미지 백필 스크립트와 필요한 원본 유틸 파일 포함. +- 카드·본문 이미지 백필 스크립트가 Nuxt repository source import에 의존하지 않고 DB를 직접 조회하도록 수정. +- 로컬 이미지 백필 명령이 `.env.production`을 자동 탐색하지 않도록 조정해 운영 DB 오연결 위험 축소. +- 배포 문서의 운영 보조 명령을 npm 없는 NAS 기준으로 보정. + +## v1.10.5 + +- 기존 게시물 본문 이미지의 표시용 WebP(`thumbs/*-body.webp`)를 일괄 생성하는 백필 스크립트 추가. +- `npm run images:backfill-post-body-thumbnails` 운영 보조 명령 추가. +- 본문 이미지 최적화 후속 과제에서 백필 스크립트 미구현 항목 제거. + ## v1.10.4 - 공개 Markdown 본문 블록 사이 Vue fragment 주석(``)을 줄이도록 블록 그룹 렌더러와 인아티클 광고 블록 삽입 방식 정리. diff --git a/lib/post-card-thumbnail-url.js b/lib/post-card-thumbnail-url.js index 5a326b9..bed4fbc 100644 --- a/lib/post-card-thumbnail-url.js +++ b/lib/post-card-thumbnail-url.js @@ -1,11 +1,11 @@ const postCardThumbnailUrlPattern = /^\/uploads\/posts\/\d{4}\/\d{2}\/thumbs\/[^/?#]+-card\.webp$/i -const postBodyThumbnailUrlPattern = /^\/uploads\/posts\/\d{4}\/\d{2}\/thumbs\/[^/?#]+-body\.webp$/i +const postBodyThumbnailUrlPattern = /^\/uploads\/posts\/\d{4}\/\d{2}\/thumbs\/[^/?#]+-body(?:-720)?\.webp$/i const postUploadImageUrlPattern = /^\/uploads\/posts\/\d{4}\/\d{2}\/[^/?#]+\.(?:jpe?g|png|webp)$/i /** * 게시물 원본 이미지 URL에 대응하는 파생 이미지 URL을 만든다. * @param {string|null|undefined} imageUrl - 원본 이미지 URL - * @param {'body'} kind - 파생 이미지 종류 + * @param {'body'|'body-720'} kind - 파생 이미지 종류 * @returns {string} 파생 이미지 URL */ const getPostDerivativeImageUrl = (imageUrl, kind) => { @@ -48,3 +48,10 @@ export const isPostBodyThumbnailUrl = (imageUrl) => Boolean( * @returns {string} 본문 표시용 파생 이미지 URL */ export const getPostBodyThumbnailUrl = (imageUrl) => getPostDerivativeImageUrl(imageUrl, 'body') + +/** + * 게시물 원본 이미지 URL에 대응하는 작은 본문 표시용 이미지 URL을 만든다. + * @param {string|null|undefined} imageUrl - 원본 이미지 URL + * @returns {string} 작은 본문 표시용 파생 이미지 URL + */ +export const getPostBodyPreviewThumbnailUrl = (imageUrl) => getPostDerivativeImageUrl(imageUrl, 'body-720') diff --git a/package-lock.json b/package-lock.json index 2bd4ad9..b6244a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.10.4", + "version": "1.10.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.10.4", + "version": "1.10.7", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 868bde2..f3289f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.10.4", + "version": "1.10.7", "private": true, "type": "module", "imports": { @@ -23,6 +23,7 @@ "db:migrate:prod:status": "sh scripts/migrate-production-db.sh status", "db:migrate:prod:baseline": "sh scripts/migrate-production-db.sh baseline", "images:backfill-post-thumbnails": "node scripts/backfill-post-thumbnails.js", + "images:backfill-post-body-thumbnails": "node scripts/backfill-post-body-thumbnails.js", "navigation:refresh-recommended-favicons": "node scripts/refresh-recommended-favicons.js", "postinstall": "nuxt prepare" }, diff --git a/scripts/backfill-post-body-thumbnails-production.sh b/scripts/backfill-post-body-thumbnails-production.sh new file mode 100755 index 0000000..9f52588 --- /dev/null +++ b/scripts/backfill-post-body-thumbnails-production.sh @@ -0,0 +1,40 @@ +#!/bin/sh +set -eu + +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +APP_SERVICE=${APP_SERVICE:-sori-studio} +COMPOSE_ENV_FILE= + +cd "$ROOT_DIR" + +pick_compose_env_file() { + if [ -n "${ENV_FILE:-}" ] && [ -r "$ENV_FILE" ]; then + COMPOSE_ENV_FILE=$ENV_FILE + return 0 + fi + + if [ -r .env.production ]; then + COMPOSE_ENV_FILE=.env.production + return 0 + fi + + if [ -r .env ]; then + COMPOSE_ENV_FILE=.env + return 0 + fi + + COMPOSE_ENV_FILE= +} + +compose() { + if [ -n "$COMPOSE_ENV_FILE" ]; then + docker compose --env-file "$COMPOSE_ENV_FILE" "$@" + return 0 + fi + + docker compose "$@" +} + +pick_compose_env_file +compose up -d "$APP_SERVICE" +compose exec -T "$APP_SERVICE" node .output/server/scripts/backfill-post-body-thumbnails.js diff --git a/scripts/backfill-post-body-thumbnails.js b/scripts/backfill-post-body-thumbnails.js new file mode 100644 index 0000000..68d75e2 --- /dev/null +++ b/scripts/backfill-post-body-thumbnails.js @@ -0,0 +1,112 @@ +import { existsSync, readFileSync } from 'node:fs' +import postgres from 'postgres' +import { + createPostBodyThumbnailForImageUrl, + extractPostImageUrlsFromContent, + getExistingPostBodyPreviewThumbnailUrl, + getExistingPostBodyThumbnailUrl, + getPostBodyThumbnailUrl +} from '../server/utils/post-thumbnail-image.js' + +/** + * 로컬 실행용 환경 파일을 process.env에 반영한다. + * @returns {void} + */ +const loadEnvironmentFile = () => { + const candidates = [ + process.env.ENV_FILE, + '.env.development', + '.env' + ].filter(Boolean) + const envFile = candidates.find((filePath) => existsSync(filePath)) + + if (!envFile) { + return + } + + const lines = readFileSync(envFile, 'utf8').split(/\r?\n/) + + for (const line of lines) { + const trimmedLine = line.trim() + + if (!trimmedLine || trimmedLine.startsWith('#')) { + continue + } + + const separatorIndex = trimmedLine.indexOf('=') + + if (separatorIndex === -1) { + continue + } + + const key = trimmedLine.slice(0, separatorIndex).trim() + const rawValue = trimmedLine.slice(separatorIndex + 1).trim() + + if (!key || process.env[key] !== undefined) { + continue + } + + process.env[key] = rawValue.replace(/^['"]|['"]$/g, '') + } +} + +/** + * 게시물 본문 목록을 조회한다. + * @returns {Promise>} 게시물 본문 목록 + */ +const listPostContents = async () => { + loadEnvironmentFile() + + if (!process.env.DATABASE_URL) { + throw new Error('DATABASE_URL 환경 변수가 필요합니다.') + } + + const sql = postgres(process.env.DATABASE_URL, { + max: 1, + idle_timeout: 5, + connect_timeout: 10 + }) + + try { + return await sql` + SELECT content + FROM posts + WHERE content IS NOT NULL + ORDER BY COALESCE(published_at, updated_at) DESC + ` + } finally { + await sql.end({ timeout: 5 }) + } +} + +const postContents = await listPostContents() +const imageUrls = [...new Set(postContents + .flatMap((post) => extractPostImageUrlsFromContent(post.content)) + .filter((url) => getPostBodyThumbnailUrl(url)))] +let createdCount = 0 +let skippedCount = 0 +let failedCount = 0 + +for (const imageUrl of imageUrls) { + if (getExistingPostBodyThumbnailUrl(imageUrl) && getExistingPostBodyPreviewThumbnailUrl(imageUrl)) { + skippedCount += 1 + continue + } + + const thumbnailUrl = await createPostBodyThumbnailForImageUrl(imageUrl) + + if (thumbnailUrl) { + createdCount += 1 + } else { + failedCount += 1 + } +} + +process.stdout.write([ + '게시물 본문 표시용 이미지 생성 완료:', + `게시물 ${postContents.length}개`, + `대상 ${imageUrls.length}개`, + `생성 ${createdCount}개`, + `기존 ${skippedCount}개`, + `실패 ${failedCount}개` +].join(' ') + '\n') diff --git a/scripts/backfill-post-thumbnails-production.sh b/scripts/backfill-post-thumbnails-production.sh new file mode 100755 index 0000000..1602dc5 --- /dev/null +++ b/scripts/backfill-post-thumbnails-production.sh @@ -0,0 +1,40 @@ +#!/bin/sh +set -eu + +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +APP_SERVICE=${APP_SERVICE:-sori-studio} +COMPOSE_ENV_FILE= + +cd "$ROOT_DIR" + +pick_compose_env_file() { + if [ -n "${ENV_FILE:-}" ] && [ -r "$ENV_FILE" ]; then + COMPOSE_ENV_FILE=$ENV_FILE + return 0 + fi + + if [ -r .env.production ]; then + COMPOSE_ENV_FILE=.env.production + return 0 + fi + + if [ -r .env ]; then + COMPOSE_ENV_FILE=.env + return 0 + fi + + COMPOSE_ENV_FILE= +} + +compose() { + if [ -n "$COMPOSE_ENV_FILE" ]; then + docker compose --env-file "$COMPOSE_ENV_FILE" "$@" + return 0 + fi + + docker compose "$@" +} + +pick_compose_env_file +compose up -d "$APP_SERVICE" +compose exec -T "$APP_SERVICE" node .output/server/scripts/backfill-post-thumbnails.js diff --git a/scripts/backfill-post-thumbnails.js b/scripts/backfill-post-thumbnails.js index 83dd678..ff92dff 100644 --- a/scripts/backfill-post-thumbnails.js +++ b/scripts/backfill-post-thumbnails.js @@ -1,11 +1,87 @@ -import { listAdminPosts } from '../server/repositories/content-repository.js' +import { existsSync, readFileSync } from 'node:fs' +import postgres from 'postgres' import { createPostThumbnailForImageUrl, getExistingPostThumbnailUrl, getPostThumbnailUrl } from '../server/utils/post-thumbnail-image.js' -const posts = await listAdminPosts() +/** + * 로컬 실행용 환경 파일을 process.env에 반영한다. + * @returns {void} + */ +const loadEnvironmentFile = () => { + const candidates = [ + process.env.ENV_FILE, + '.env.development', + '.env' + ].filter(Boolean) + const envFile = candidates.find((filePath) => existsSync(filePath)) + + if (!envFile) { + return + } + + const lines = readFileSync(envFile, 'utf8').split(/\r?\n/) + + for (const line of lines) { + const trimmedLine = line.trim() + + if (!trimmedLine || trimmedLine.startsWith('#')) { + continue + } + + const separatorIndex = trimmedLine.indexOf('=') + + if (separatorIndex === -1) { + continue + } + + const key = trimmedLine.slice(0, separatorIndex).trim() + const rawValue = trimmedLine.slice(separatorIndex + 1).trim() + + if (!key || process.env[key] !== undefined) { + continue + } + + process.env[key] = rawValue.replace(/^['"]|['"]$/g, '') + } +} + +/** + * 게시물 대표 이미지 목록을 조회한다. + * @returns {Promise>} 대표 이미지 목록 + */ +const listPostFeaturedImages = async () => { + loadEnvironmentFile() + + if (!process.env.DATABASE_URL) { + throw new Error('DATABASE_URL 환경 변수가 필요합니다.') + } + + const sql = postgres(process.env.DATABASE_URL, { + max: 1, + idle_timeout: 5, + connect_timeout: 10 + }) + + try { + const rows = await sql` + SELECT featured_image + FROM posts + WHERE featured_image IS NOT NULL + ORDER BY COALESCE(published_at, updated_at) DESC + ` + + return rows.map((row) => ({ + featuredImage: row.featured_image + })) + } finally { + await sql.end({ timeout: 5 }) + } +} + +const posts = await listPostFeaturedImages() const featuredImageUrls = [...new Set(posts .map((post) => post.featuredImage) .filter((url) => getPostThumbnailUrl(url)))] diff --git a/server/utils/post-thumbnail-image.js b/server/utils/post-thumbnail-image.js index 87b4a76..56dfdc1 100644 --- a/server/utils/post-thumbnail-image.js +++ b/server/utils/post-thumbnail-image.js @@ -3,17 +3,19 @@ import { mkdir, stat } from 'node:fs/promises' import { dirname, extname, join, posix } from 'node:path' import sharp from 'sharp' import { + getPostBodyPreviewThumbnailUrl, getPostBodyThumbnailUrl, isPostBodyThumbnailUrl, isPostCardThumbnailUrl } from '../../lib/post-card-thumbnail-url.js' -export { getPostBodyThumbnailUrl, isPostBodyThumbnailUrl, isPostCardThumbnailUrl } +export { getPostBodyPreviewThumbnailUrl, 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_PREVIEW_IMAGE_WIDTH = 720 export const POST_BODY_IMAGE_QUALITY = 84 const postUploadUrlPattern = /^\/uploads\/posts\/\d{4}\/\d{2}\/[^/?#]+$/i @@ -114,6 +116,21 @@ export const getPostBodyThumbnailDiskPath = (imageUrl) => { return join(process.cwd(), 'public', decodeUrlPathPart(thumbnailUrl.replace(/^\/+/, ''))) } +/** + * 게시물 작은 본문 표시용 이미지 URL의 디스크 경로를 조회한다. + * @param {string|null|undefined} imageUrl - 원본 이미지 URL + * @returns {string} 작은 본문 표시용 이미지 디스크 경로 + */ +export const getPostBodyPreviewThumbnailDiskPath = (imageUrl) => { + const thumbnailUrl = getPostBodyPreviewThumbnailUrl(imageUrl) + + if (!thumbnailUrl) { + return '' + } + + return join(process.cwd(), 'public', decodeUrlPathPart(thumbnailUrl.replace(/^\/+/, ''))) +} + /** * 게시물 원본 이미지 URL의 디스크 경로를 조회한다. * @param {string|null|undefined} imageUrl - 원본 이미지 URL @@ -175,6 +192,21 @@ export const getExistingPostBodyThumbnailUrl = (imageUrl) => { return existsSync(getPostBodyThumbnailDiskPath(imageUrl)) ? thumbnailUrl : '' } +/** + * 이미 생성된 작은 게시물 본문 표시용 이미지 URL을 조회한다. + * @param {string|null|undefined} imageUrl - 원본 이미지 URL + * @returns {string} 존재하는 작은 본문 표시용 이미지 URL + */ +export const getExistingPostBodyPreviewThumbnailUrl = (imageUrl) => { + const thumbnailUrl = getPostBodyPreviewThumbnailUrl(imageUrl) + + if (!thumbnailUrl) { + return '' + } + + return existsSync(getPostBodyPreviewThumbnailDiskPath(imageUrl)) ? thumbnailUrl : '' +} + /** * 게시물 대표 이미지의 카드 썸네일을 생성하거나 다시 생성한다. * @param {string|null|undefined} imageUrl - 원본 이미지 URL @@ -221,8 +253,9 @@ export const createPostBodyThumbnailForImageUrl = async (imageUrl) => { const sourcePath = getPostImageDiskPath(imageUrl) const thumbnailUrl = getPostBodyThumbnailUrl(imageUrl) const thumbnailPath = getPostBodyThumbnailDiskPath(imageUrl) + const previewThumbnailPath = getPostBodyPreviewThumbnailDiskPath(imageUrl) - if (!sourcePath || !thumbnailUrl || !thumbnailPath) { + if (!sourcePath || !thumbnailUrl || !thumbnailPath || !previewThumbnailPath) { return '' } @@ -244,6 +277,16 @@ export const createPostBodyThumbnailForImageUrl = async (imageUrl) => { }) .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) } catch { return '' }