공개 본문 블록 계산 분리

This commit is contained in:
2026-07-27 12:38:21 +09:00
parent b15282f41f
commit 8c4ad90f91
10 changed files with 214 additions and 83 deletions

View File

@@ -40,11 +40,9 @@ import {
getQuoteLineEntries,
normalizeQuoteEditableValue
} from '../../lib/markdown-quote.js'
import { getInArticleAdAfterBlockIds } from '../../lib/content-in-article-ad.js'
import { getContentLightboxImages, getGalleryRows } from '../../lib/content-gallery-images.js'
import { parseMarkdownBlocks } from '../../lib/markdown-block-parser.js'
import { createHeadingIdFactory } from '../../lib/markdown-toc.js'
import { getGalleryRows } from '../../lib/content-gallery-images.js'
import { useContentMarkdownLightbox } from '../../composables/useContentMarkdownLightbox.js'
import { useContentMarkdownBlocks } from '../../composables/useContentMarkdownBlocks.js'
import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue'
import ProseCodeBlock from './ProseCodeBlock.vue'
import ProseHtml from './ProseHtml.vue'
@@ -132,6 +130,19 @@ const {
showNextImage
} = useContentMarkdownLightbox()
const {
blocks,
normalizedInArticleAdCode,
headingIdsByBlock,
renderBlocks,
tailInsertBeforeLine,
contentLightboxImages
} = useContentMarkdownBlocks({
content: computed(() => props.content),
interactive: computed(() => props.interactive),
inArticleAdCode: computed(() => props.inArticleAdCode)
})
/**
* 빈 줄 공백 블록 높이를 반환한다.
* @param {Object} block - 렌더링 블록
@@ -139,79 +150,6 @@ const {
*/
const getSpacerHeightClass = (block) => block.meta?.legacy ? 'h-6' : 'h-8'
const blocks = computed(() => parseMarkdownBlocks(props.content))
const normalizedInArticleAdCode = computed(() => String(props.inArticleAdCode || '').trim())
/**
* 본문 인아티클 광고를 삽입할 블록 ID 목록을 반환한다.
* @returns {string[]} 삽입 기준 블록 ID 목록
*/
const inArticleAdAfterBlockIds = computed(() => {
if (props.interactive || !normalizedInArticleAdCode.value) {
return []
}
return getInArticleAdAfterBlockIds(blocks.value)
})
const headingIdsByBlock = computed(() => {
const createHeadingId = createHeadingIdFactory()
const ids = {}
for (const block of blocks.value) {
if (block.type === 'heading' && block.level >= 1 && block.level <= 3) {
ids[block.id] = createHeadingId(block.text)
}
}
return ids
})
/**
* 공개 본문 렌더링용 블록 목록을 반환한다.
* 인아티클 광고는 별도 v-if 대신 블록으로 삽입해 fragment 주석을 줄인다.
* @returns {Array<Object>} 렌더링 블록 목록
*/
const renderBlocks = computed(() => {
const sourceBlocks = blocks.value
const adAfterBlockIds = inArticleAdAfterBlockIds.value
if (props.interactive || !adAfterBlockIds.length) {
return sourceBlocks
}
const adAfterIdSet = new Set(adAfterBlockIds)
const result = []
for (const block of sourceBlocks) {
result.push(block)
if (adAfterIdSet.has(block.id)) {
result.push({
id: `in-article-ad-after-${block.id}`,
type: 'in-article-ad',
meta: {
afterBlockId: block.id
}
})
}
}
return result
})
/** @type {import('vue').ComputedRef<number>} 문서 맨 아래 단일 이미지 삽입 줄 */
const tailInsertBeforeLine = computed(() => {
const lastBlock = blocks.value[blocks.value.length - 1]
if (typeof lastBlock?.meta?.endLine === 'number') {
return lastBlock.meta.endLine + 1
}
return (props.content || '').split('\n').length
})
const contentLightboxImages = computed(() => getContentLightboxImages(blocks.value))
/**
* 갤러리 이미지 비율 저장 키를 만든다.
* @param {string} blockId - 블록 ID

View File

@@ -0,0 +1,107 @@
import { computed, unref } from 'vue'
import { getInArticleAdAfterBlockIds } from '../lib/content-in-article-ad.js'
import { getContentLightboxImages } from '../lib/content-gallery-images.js'
import { parseMarkdownBlocks } from '../lib/markdown-block-parser.js'
import { createHeadingIdFactory } from '../lib/markdown-toc.js'
/**
* 공개 Markdown 본문의 렌더링 블록 계산 상태를 만든다.
* @param {Object} options - 계산 옵션
* @param {import('vue').Ref<string>|string} options.content - Markdown 본문
* @param {import('vue').Ref<boolean>|boolean} options.interactive - 관리자 라이브 편집 여부
* @param {import('vue').Ref<string>|string} options.inArticleAdCode - 인아티클 광고 코드
* @returns {{
* blocks: import('vue').ComputedRef<Array<Object>>,
* normalizedInArticleAdCode: import('vue').ComputedRef<string>,
* inArticleAdAfterBlockIds: import('vue').ComputedRef<string[]>,
* headingIdsByBlock: import('vue').ComputedRef<Record<string, string>>,
* renderBlocks: import('vue').ComputedRef<Array<Object>>,
* tailInsertBeforeLine: import('vue').ComputedRef<number>,
* contentLightboxImages: import('vue').ComputedRef<Array<Object>>
* }} 본문 블록 계산 결과
*/
export const useContentMarkdownBlocks = (options) => {
const blocks = computed(() => parseMarkdownBlocks(String(unref(options.content) || '')))
const normalizedInArticleAdCode = computed(() => String(unref(options.inArticleAdCode) || '').trim())
/**
* 본문 인아티클 광고를 삽입할 블록 ID 목록을 반환한다.
* @returns {string[]} 삽입 기준 블록 ID 목록
*/
const inArticleAdAfterBlockIds = computed(() => {
if (unref(options.interactive) || !normalizedInArticleAdCode.value) {
return []
}
return getInArticleAdAfterBlockIds(blocks.value)
})
const headingIdsByBlock = computed(() => {
const createHeadingId = createHeadingIdFactory()
const ids = {}
for (const block of blocks.value) {
if (block.type === 'heading' && block.level >= 1 && block.level <= 3) {
ids[block.id] = createHeadingId(block.text)
}
}
return ids
})
/**
* 공개 본문 렌더링용 블록 목록을 반환한다.
* 인아티클 광고는 별도 v-if 대신 블록으로 삽입해 fragment 주석을 줄인다.
* @returns {Array<Object>} 렌더링 블록 목록
*/
const renderBlocks = computed(() => {
const sourceBlocks = blocks.value
const adAfterBlockIds = inArticleAdAfterBlockIds.value
if (unref(options.interactive) || !adAfterBlockIds.length) {
return sourceBlocks
}
const adAfterIdSet = new Set(adAfterBlockIds)
const result = []
for (const block of sourceBlocks) {
result.push(block)
if (adAfterIdSet.has(block.id)) {
result.push({
id: `in-article-ad-after-${block.id}`,
type: 'in-article-ad',
meta: {
afterBlockId: block.id
}
})
}
}
return result
})
/** @type {import('vue').ComputedRef<number>} 문서 맨 아래 단일 이미지 삽입 줄 */
const tailInsertBeforeLine = computed(() => {
const lastBlock = blocks.value[blocks.value.length - 1]
if (typeof lastBlock?.meta?.endLine === 'number') {
return lastBlock.meta.endLine + 1
}
return String(unref(options.content) || '').split('\n').length
})
const contentLightboxImages = computed(() => getContentLightboxImages(blocks.value))
return {
blocks,
normalizedInArticleAdCode,
inArticleAdAfterBlockIds,
headingIdsByBlock,
renderBlocks,
tailInsertBeforeLine,
contentLightboxImages
}
}

View File

@@ -1,3 +1,7 @@
## 2026-07-27 v1.11.96 — 공개 Markdown 블록 계산을 렌더러 밖으로 옮긴다
`ContentMarkdownRenderer`는 공개 본문 표시와 관리자 라이브 편집을 모두 연결하므로, 파싱·광고 삽입·제목 ID·라이트박스 대상 같은 계산까지 같은 파일에 있으면 실제 UI 책임과 데이터 준비 책임이 섞인다. 본문 문자열에서 렌더링 블록을 준비하는 계층을 `useContentMarkdownBlocks`로 분리해 렌더러는 준비된 블록을 화면에 배치하고 라이브 편집 이벤트를 중개하는 역할에 더 집중하도록 한다.
## 2026-07-27 v1.11.95 — 공개 Markdown 라이트박스 상태를 분리한다
공개 Markdown 렌더러는 본문 블록 표시와 관리자 라이브 편집 중개가 함께 있어 작은 전역 이벤트 상태가 파일 안에 남아도 추적 비용이 커진다. 단일 이미지·갤러리 라이트박스의 열린 이미지 목록, 현재 인덱스, 좌우 이동, ESC 닫기, 키보드 이벤트 연결/해제를 `useContentMarkdownLightbox`로 분리해 렌더러는 클릭한 이미지 키를 전달하고 UI를 표시하는 역할에 집중한다.

View File

@@ -26,6 +26,7 @@
| composables/useAdminFeaturedImagePicker.js | 관리자 게시물 폼 대표 이미지 선택 모달의 검색·페이지네이션·업로드·선택 상태 |
| composables/useAdminPostPublishState.js | 관리자 게시물 폼 발행·예약·발행취소 상태와 날짜 입력 상태 |
| composables/useAdminMarkdownSlashMenu.js | 관리자 Markdown 에디터 슬래시 메뉴 상태·검색 결과·억제 줄·키보드 탐색 타이머 관리 |
| composables/useContentMarkdownBlocks.js | 공개 Markdown 본문 파싱·제목 ID·인아티클 광고 렌더 블록·하단 삽입 줄·라이트박스 대상 목록 계산 |
| composables/useContentMarkdownLightbox.js | 공개 Markdown 본문 단일 이미지·갤러리 통합 라이트박스 상태·좌우 이동·ESC 닫기·키보드 이벤트 연결 관리 |
| composables/useAdminMediaThumbnailRegeneration.js | 관리자 미디어 화면 대표 이미지·페이지 커버·본문 이미지 재생성 가능 여부와 API 호출 상태 |
| composables/usePublicSiteSettings.js | 공개 화면의 `/api/site-settings` 조회 key(`site-settings-public`)와 기본 설정값 통일 |
@@ -130,6 +131,7 @@
| tests/markdown-slash-commands.test.js | 관리자 슬래시 명령 파싱·미디어 action·모달 종류·제외 줄 Set 갱신·하이라이트 인덱스 보정·방향키 순환·키보드 동작 분류·포커스 대상·textarea 선택 위치·원문 줄 교체 삽입 단위 회귀 테스트 |
| tests/markdown-slash-menu-scroll.test.js | 관리자 슬래시 메뉴 선택 항목 scrollTop 보정 단위 테스트 |
| tests/useAdminMarkdownSlashMenu.test.js | 관리자 Markdown 에디터 슬래시 메뉴 composable 상태 관리 단위 테스트 |
| tests/useContentMarkdownBlocks.test.js | 공개 Markdown 블록 계산 composable의 파싱·제목 ID·광고 삽입·하단 삽입 줄 단위 테스트 |
| tests/useContentMarkdownLightbox.test.js | 공개 Markdown 라이트박스 composable의 열기·좌우 순환·ESC 닫기·키보드 이벤트 해제 단위 테스트 |
| tests/markdown-block-parser.test.js | 공개 Markdown 본문 블록 파싱 단위 회귀 테스트 |
| tests/markdown-fenced-meta.test.js | Markdown fenced 블록 시작·종료 줄 탐색, 본문 수집, 메타 파싱 단위 회귀 테스트 |

View File

@@ -127,7 +127,7 @@
- 관리자 미디어 업로드 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-1600.webp`와 720px `thumbs/*-body-720.webp`를 함께 생성하고, 공개 본문 단일 이미지·갤러리는 `lib/content-image-urls.js`의 `srcset` 계산으로 화면 폭에 맞는 파일을 우선 사용한다. 공개 본문 라이트박스 대상 목록과 갤러리 행 분할은 `lib/content-gallery-images.js`에서 계산한다.
- 본문 표시용 이미지는 1600px `thumbs/*-body-1600.webp`와 720px `thumbs/*-body-720.webp`를 함께 생성하고, 공개 본문 단일 이미지·갤러리는 `lib/content-image-urls.js`의 `srcset` 계산으로 화면 폭에 맞는 파일을 우선 사용한다. 공개 본문 라이트박스 대상 목록은 `useContentMarkdownBlocks`에서 `lib/content-gallery-images.js` 기준으로 계산하고, 갤러리 행 분할은 `lib/content-gallery-images.js`에서 계산한다.
- 라이트박스용 이미지는 2560px `thumbs/*-body-2560.webp`를 생성해 크게 보기 화면에서 우선 사용한다. 별도 원본 보기 UI는 제공하지 않으며, `lib/content-image-urls.js`가 파생 URL을 선택하고 파생 이미지 로드 실패 시에만 원본으로 fallback한다.
- 기존 운영 볼륨에 생성된 `thumbs/*-body.webp`, `thumbs/*-lightbox.webp`는 레거시 본문 파생 이미지로 계속 인식해 관리자 미디어의 본문 이미지 탭과 사용처 연결을 유지한다. 새 저장·재생성·백필은 해상도형 파일명만 만든다.
- 관리자 미디어의 원본 이미지 사용처는 원본 URL 직접 참조뿐 아니라 해당 원본에서 생성된 카드·페이지 커버·본문 파생 이미지 URL 사용처까지 함께 반영한다. 사용처 계산은 관리자 글 목록 요약 API와 분리된 미디어 참조 전용 조회로 게시물·페이지의 본문과 대표 이미지만 확인한다. 따라서 공개 본문이 `body-1600` 같은 파생 이미지를 사용하더라도 원본은 미사용 삭제 대상에서 제외되고, 본문 파생 이미지도 실제 본문 사용처가 있으면 미사용으로 분류하지 않는다. 원본이 실제로 미사용이라 삭제될 때는 연결된 카드·페이지 커버·본문 자동 생성 이미지도 함께 삭제한다.
@@ -306,7 +306,7 @@ components/content/
- 렌더링: `ProseImage.vue` (라운드/보더/패널 배경). 클릭 시 라이트박스로 확대하며, 같은 게시물 안의 단일 이미지와 갤러리 이미지를 `lib/content-gallery-images.js` 기준 문서 순서대로 하나의 그룹으로 묶어 Esc 닫기·←/→ 이전·다음 탐색을 지원한다. 라이트박스 상태와 키보드 이벤트 연결은 `useContentMarkdownLightbox`에서 관리한다.
- 이미지 갤러리
- `:::gallery` ~ `:::` fenced block 내부에 이미지 마크다운 행을 여러 개 작성
- 렌더링: `ContentMarkdownRenderer.vue` (최대 3개 단위 행 + 게시물 전체 이미지 라이트박스, Esc 닫기·←/→ 이전·다음). 행 분할과 라이트박스 목록 생성은 `lib/content-gallery-images.js`에서 수행하고, 라이트박스 상태 관리는 `useContentMarkdownLightbox`에서 수행한다.
- 렌더링: `ContentMarkdownRenderer.vue` (최대 3개 단위 행 + 게시물 전체 이미지 라이트박스, Esc 닫기·←/→ 이전·다음). 행 분할은 `lib/content-gallery-images.js`에서 수행하고, 라이트박스 목록 생성은 `useContentMarkdownBlocks`, 라이트박스 상태 관리는 `useContentMarkdownLightbox`에서 수행한다.
- 갤러리 행은 1개일 때 전체 폭, 2~3개일 때 행 전체 폭을 나눠 쓰며 이미지 로드 후 자연 비율(가로/세로)에 따라 셀 너비를 조정한다.
- 비디오·오디오·파일 카드
- 비디오: `:::video` ~ `:::` (`url`, `title`, `poster`, `caption` 키값 또는 URL 단독 줄)

View File

@@ -4,7 +4,7 @@
- 거대 컴포넌트 책임 분리 진행.
- `components/admin/AdminMarkdownEditor.vue`: 미디어 모달, 슬래시 명령, 히스토리·선택 복원, 라이브/소스 전환 로직을 단계적으로 composable 또는 하위 컴포넌트로 분리.
- `components/content/ContentMarkdownRenderer.vue`: 파싱·블록 렌더링·라이브 편집 중개·광고 삽입 책임을 분리.
- `components/content/ContentMarkdownRenderer.vue`: 블록 렌더링 UI와 라이브 편집 중개 책임을 분리.
- 미디어 카탈로그 전환 코드 수명 정리.
- 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토.
- fallback은 운영 안정성에 필요하므로 제거 전 reconcile·로그·테스트 기준을 먼저 정리.

View File

@@ -1,5 +1,10 @@
# 업데이트 이력
## v1.11.96
- 공개 Markdown 렌더러의 본문 파싱·제목 ID·인아티클 광고 삽입 렌더 블록·하단 삽입 줄·라이트박스 대상 목록 계산을 `useContentMarkdownBlocks` composable로 분리.
- 공개 Markdown 블록 계산 composable의 제목 ID, 광고 삽입 제외, 하단 삽입 줄 단위 테스트 추가.
## v1.11.95
- 공개 Markdown 렌더러의 이미지 라이트박스 상태·좌우 이동·ESC 닫기·전역 키보드 이벤트 관리를 `useContentMarkdownLightbox` composable로 분리.

4
package-lock.json generated
View File

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

View File

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

View File

@@ -0,0 +1,75 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { ref } from 'vue'
import { useContentMarkdownBlocks } from '../composables/useContentMarkdownBlocks.js'
/**
* 긴 본문 테스트용 Markdown을 만든다.
* @param {number} count - 문단 개수
* @param {number} length - 문단 길이
* @returns {string} Markdown 본문
*/
const createLongMarkdown = (count, length) => {
return Array.from({ length: count }, (_, index) => `문단 ${index} ${'가'.repeat(length)}`).join('\n\n')
}
test('공개 Markdown 블록 계산은 본문 파싱과 제목 ID 생성을 관리한다', () => {
const state = useContentMarkdownBlocks({
content: [
'# 같은 제목',
'## 같은 제목',
'',
'![](/uploads/posts/2026/07/a.jpg)',
':::gallery',
'![](/uploads/posts/2026/07/b.jpg)',
':::'
].join('\n'),
interactive: false,
inArticleAdCode: ''
})
assert.deepEqual(state.blocks.value.map((block) => block.type), ['heading', 'heading', 'spacer', 'image', 'gallery'])
assert.deepEqual(Object.values(state.headingIdsByBlock.value), ['같은-제목', '같은-제목-2'])
assert.deepEqual(
state.contentLightboxImages.value.map((image) => image.url),
['/uploads/posts/2026/07/a.jpg', '/uploads/posts/2026/07/b.jpg']
)
})
test('공개 Markdown 블록 계산은 광고 코드를 렌더 블록에 삽입하고 라이브 편집에서는 제외한다', () => {
const content = ref(createLongMarkdown(14, 180))
const interactive = ref(false)
const inArticleAdCode = ref('<ins>ad</ins>')
const state = useContentMarkdownBlocks({ content, interactive, inArticleAdCode })
assert.equal(state.normalizedInArticleAdCode.value, '<ins>ad</ins>')
assert.equal(state.inArticleAdAfterBlockIds.value.length, 1)
assert.equal(state.renderBlocks.value.filter((block) => block.type === 'in-article-ad').length, 1)
interactive.value = true
assert.deepEqual(state.inArticleAdAfterBlockIds.value, [])
assert.equal(state.renderBlocks.value.some((block) => block.type === 'in-article-ad'), false)
})
test('공개 Markdown 블록 계산은 마지막 블록 범위 기준으로 하단 삽입 줄을 계산한다', () => {
const content = ref([
'첫 문단',
'',
':::gallery',
'![](/uploads/posts/2026/07/a.jpg)',
'![](/uploads/posts/2026/07/b.jpg)',
':::'
].join('\n'))
const state = useContentMarkdownBlocks({
content,
interactive: true,
inArticleAdCode: ''
})
assert.equal(state.tailInsertBeforeLine.value, 6)
content.value = '한 줄'
assert.equal(state.tailInsertBeforeLine.value, 1)
})