From ff2d78ff811c06c74ec82c00ebe3595be6bb3b82 Mon Sep 17 00:00:00 2001 From: zenn Date: Mon, 27 Jul 2026 16:03:50 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B3=B5=EA=B0=9C=20=EB=B3=B8=EB=AC=B8=20fence?= =?UTF-8?q?d=20=EB=B8=94=EB=A1=9D=20=ED=8E=B8=EC=A7=91=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/ContentMarkdownRenderer.vue | 119 ++---------- composables/useContentMarkdownFencedBlocks.js | 179 ++++++++++++++++++ docs/history.md | 4 + docs/map.md | 2 + docs/spec.md | 2 +- docs/todo.md | 2 +- docs/update.md | 6 + package-lock.json | 4 +- package.json | 2 +- tests/useContentMarkdownFencedBlocks.test.js | 104 ++++++++++ 10 files changed, 319 insertions(+), 105 deletions(-) create mode 100644 composables/useContentMarkdownFencedBlocks.js create mode 100644 tests/useContentMarkdownFencedBlocks.test.js diff --git a/components/content/ContentMarkdownRenderer.vue b/components/content/ContentMarkdownRenderer.vue index cc21f43..4bb0fca 100644 --- a/components/content/ContentMarkdownRenderer.vue +++ b/components/content/ContentMarkdownRenderer.vue @@ -18,8 +18,7 @@ import { parseOrderedListMarker, stripListMarker } from '../../lib/markdown-live-edit.js' -import { buildCodeBlockLines, buildCodeFenceOpener } from '../../lib/markdown-code-block.js' -import { buildToggleBlockLines } from '../../lib/markdown-toggle.js' +import { buildCodeFenceOpener } from '../../lib/markdown-code-block.js' import { buildCalloutOpenerLine, CALLOUT_BACKGROUND_OPTIONS @@ -35,6 +34,7 @@ import { useContentMarkdownLightbox } from '../../composables/useContentMarkdown import { useContentMarkdownBlocks } from '../../composables/useContentMarkdownBlocks.js' import { useContentMarkdownImageDrag } from '../../composables/useContentMarkdownImageDrag.js' import { useContentMarkdownLiveSelection } from '../../composables/useContentMarkdownLiveSelection.js' +import { useContentMarkdownFencedBlocks } from '../../composables/useContentMarkdownFencedBlocks.js' import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue' import ProseCodeBlock from './ProseCodeBlock.vue' import ProseHtml from './ProseHtml.vue' @@ -168,6 +168,23 @@ const { onContentReplace: (payload) => emit('content-replace', payload) }) +const { + getCodeLineNumbers, + onCodeBlockCommit, + onCodeBlockInsertBelow, + onCalloutBlockCommit, + onCalloutBlockInsertBelow, + onToggleBlockCommit, + onToggleBlockInsertBelow +} = useContentMarkdownFencedBlocks({ + content: computed(() => props.content), + interactive: computed(() => props.interactive), + pendingFocusPosition, + pendingFocusOffset, + commitInlineBlockLines: (block, replacementLines) => commitInlineBlockLines(block, replacementLines), + insertBelowBlock: (block, options) => onInsertBelowBlock(block, options) +}) + /** * 빈 줄 공백 블록 높이를 반환한다. * @param {Object} block - 렌더링 블록 @@ -437,18 +454,6 @@ const createEmptyListMarkerLine = (block, itemIndex) => { return '- ' } -/** - * 코드 블록 줄 번호 목록을 만든다. - * @param {Object} block - 코드 블록 - * @returns {number[]} 줄 번호(1부터) - */ -const getCodeLineNumbers = (block) => { - const text = String(block.text ?? '') - const lineCount = text.length ? text.split('\n').length : 1 - - return Array.from({ length: lineCount }, (_, index) => index + 1) -} - /** * 인라인 편집 결과를 마크다운 줄로 반영한다. * @param {Object} block - 블록 @@ -467,33 +472,6 @@ const commitInlineBlockLines = (block, replacementLines) => { }) } -/** - * 코드 블록 편집 반영 - * @param {Object} block - 코드 블록 - * @param {string[]} replacementLines - 마크다운 줄 - * @returns {void} - */ -const onCodeBlockCommit = (block, replacementLines) => { - commitInlineBlockLines(block, replacementLines) -} - -/** - * 코드 블록 마지막 줄에서 아래로 이탈 — 본문 저장 후 다음 문단 삽입 - * @param {Object} block - 코드 블록 - * @param {string|Object} payload - insert-below 페이로드 - * @returns {void} - */ -const onCodeBlockInsertBelow = (block, payload) => { - const { value } = normalizeInsertBelowPayload(payload) - - onCodeBlockCommit(block, buildCodeBlockLines({ - language: block.codeLanguage, - showLineNumbers: block.codeShowLineNumbers, - body: value - })) - onInsertBelowBlock(block, { lines: [''] }) -} - /** * 블록 위에 기본 문단을 삽입한다. * @param {Object} block - 기준 블록 @@ -511,65 +489,6 @@ const onInsertAboveBlock = (block) => { }) } -/** - * 콜아웃 편집 반영 - * @param {Object} block - 콜아웃 블록 - * @param {string[]} replacementLines - 마크다운 줄 - * @returns {void} - */ -const onCalloutBlockCommit = (block, replacementLines) => { - commitInlineBlockLines(block, replacementLines) -} - -/** - * 콜아웃 마지막 줄에서 아래로 이탈한다. - * @param {Object} block - 콜아웃 블록 - * @param {string|Object} payload - insert-below 페이로드 - * @returns {void} - */ -const onCalloutBlockInsertBelow = (block, payload) => { - const { value } = normalizeInsertBelowPayload(payload) - const openingLine = getMarkdownLine(block.meta.startLine) - - onCalloutBlockCommit(block, [ - openingLine, - ...String(value ?? '').replace(/\r/g, '').split('\n'), - ':::' - ]) - pendingFocusPosition.value = 'start' - pendingFocusOffset.value = 0 - onInsertBelowBlock(block, { lines: [''] }) -} - -/** - * 토글 편집 반영 - * @param {Object} block - 토글 블록 - * @param {string[]} replacementLines - 마크다운 줄 - * @returns {void} - */ -const onToggleBlockCommit = (block, replacementLines) => { - commitInlineBlockLines(block, replacementLines) -} - -/** - * 토글 본문 마지막 줄에서 아래로 이탈 - * @param {Object} block - 토글 블록 - * @param {string|Object} payload - insert-below 페이로드 - * @returns {void} - */ -const onToggleBlockInsertBelow = (block, payload) => { - const { value } = normalizeInsertBelowPayload(payload) - - onToggleBlockCommit(block, buildToggleBlockLines({ - title: block.title, - defaultOpen: block.defaultOpen, - body: value - })) - pendingFocusPosition.value = 'start' - pendingFocusOffset.value = 0 - onInsertBelowBlock(block, { lines: [''] }) -} - const onParagraphInlineCommit = (block, text) => { const value = String(text ?? '').replace(/\r/g, '') const lines = value.split('\n').map((line) => line.trimEnd()) diff --git a/composables/useContentMarkdownFencedBlocks.js b/composables/useContentMarkdownFencedBlocks.js new file mode 100644 index 0000000..905e749 --- /dev/null +++ b/composables/useContentMarkdownFencedBlocks.js @@ -0,0 +1,179 @@ +import { unref } from 'vue' +import { buildCodeBlockLines } from '../lib/markdown-code-block.js' +import { buildToggleBlockLines } from '../lib/markdown-toggle.js' + +/** + * 관리자 라이브 Markdown fenced 블록 편집 이벤트 상태를 만든다. + * @param {Object} options - fenced 블록 편집 옵션 + * @param {import('vue').Ref|boolean} options.interactive - 라이브 편집 여부 + * @param {import('vue').Ref|string} options.content - Markdown 본문 + * @param {import('vue').Ref<'start'|'end'|'auto'>} options.pendingFocusPosition - 다음 포커스 커서 위치 + * @param {import('vue').Ref} options.pendingFocusOffset - 다음 포커스 오프셋 + * @param {(block: Object, replacementLines: string[]) => void} options.commitInlineBlockLines - 블록 줄 반영 콜백 + * @param {(block: Object, options?: { lines?: string[] }) => void} options.insertBelowBlock - 블록 아래 삽입 콜백 + * @returns {Object} fenced 블록 이벤트 핸들러 + */ +export const useContentMarkdownFencedBlocks = (options) => { + /** + * insert-below 이벤트 페이로드를 정규화한다. + * @param {string|Object} payload - 페이로드 + * @returns {{ value: string, raw: boolean, before: string, after: string, caretAtStart: boolean, caretAtEnd: boolean }} + */ + const normalizeInsertBelowPayload = (payload) => { + if (typeof payload === 'string') { + return { + value: payload, + raw: false, + before: '', + after: '', + caretAtStart: false, + caretAtEnd: true + } + } + + return { + value: String(payload?.value ?? ''), + raw: payload?.raw === true, + before: String(payload?.before ?? ''), + after: String(payload?.after ?? ''), + caretAtStart: payload?.caretAtStart === true, + caretAtEnd: payload?.caretAtEnd === true + } + } + + /** + * 원본 마크다운 줄을 반환한다. + * @param {number} lineIndex - 줄 번호 + * @returns {string} 줄 텍스트 + */ + const getMarkdownLine = (lineIndex) => String(unref(options.content) || '').split('\n')[lineIndex] ?? '' + + /** + * 인라인 편집 결과를 마크다운 줄로 반영한다. + * @param {Object} block - 블록 + * @param {string[]} replacementLines - 대체 줄 + * @returns {void} + */ + const commitInlineBlockLines = (block, replacementLines) => { + if (!unref(options.interactive)) { + return + } + + options.commitInlineBlockLines(block, replacementLines) + } + + /** + * 다음 외부 문단으로 포커스 위치를 준비한다. + * @returns {void} + */ + const prepareNextParagraphFocus = () => { + options.pendingFocusPosition.value = 'start' + options.pendingFocusOffset.value = 0 + } + + /** + * 코드 블록 줄 번호 목록을 만든다. + * @param {Object} block - 코드 블록 + * @returns {number[]} 줄 번호(1부터) + */ + const getCodeLineNumbers = (block) => { + const text = String(block.text ?? '') + const lineCount = text.length ? text.split('\n').length : 1 + + return Array.from({ length: lineCount }, (_, index) => index + 1) + } + + /** + * 코드 블록 편집 반영 + * @param {Object} block - 코드 블록 + * @param {string[]} replacementLines - 마크다운 줄 + * @returns {void} + */ + const onCodeBlockCommit = (block, replacementLines) => { + commitInlineBlockLines(block, replacementLines) + } + + /** + * 코드 블록 마지막 줄에서 아래로 이탈한다. + * @param {Object} block - 코드 블록 + * @param {string|Object} payload - insert-below 페이로드 + * @returns {void} + */ + const onCodeBlockInsertBelow = (block, payload) => { + const { value } = normalizeInsertBelowPayload(payload) + + onCodeBlockCommit(block, buildCodeBlockLines({ + language: block.codeLanguage, + showLineNumbers: block.codeShowLineNumbers, + body: value + })) + options.insertBelowBlock(block, { lines: [''] }) + } + + /** + * 콜아웃 편집 반영 + * @param {Object} block - 콜아웃 블록 + * @param {string[]} replacementLines - 마크다운 줄 + * @returns {void} + */ + const onCalloutBlockCommit = (block, replacementLines) => { + commitInlineBlockLines(block, replacementLines) + } + + /** + * 콜아웃 마지막 줄에서 아래로 이탈한다. + * @param {Object} block - 콜아웃 블록 + * @param {string|Object} payload - insert-below 페이로드 + * @returns {void} + */ + const onCalloutBlockInsertBelow = (block, payload) => { + const { value } = normalizeInsertBelowPayload(payload) + const openingLine = getMarkdownLine(block.meta.startLine) + + onCalloutBlockCommit(block, [ + openingLine, + ...String(value ?? '').replace(/\r/g, '').split('\n'), + ':::' + ]) + prepareNextParagraphFocus() + options.insertBelowBlock(block, { lines: [''] }) + } + + /** + * 토글 편집 반영 + * @param {Object} block - 토글 블록 + * @param {string[]} replacementLines - 마크다운 줄 + * @returns {void} + */ + const onToggleBlockCommit = (block, replacementLines) => { + commitInlineBlockLines(block, replacementLines) + } + + /** + * 토글 본문 마지막 줄에서 아래로 이탈한다. + * @param {Object} block - 토글 블록 + * @param {string|Object} payload - insert-below 페이로드 + * @returns {void} + */ + const onToggleBlockInsertBelow = (block, payload) => { + const { value } = normalizeInsertBelowPayload(payload) + + onToggleBlockCommit(block, buildToggleBlockLines({ + title: block.title, + defaultOpen: block.defaultOpen, + body: value + })) + prepareNextParagraphFocus() + options.insertBelowBlock(block, { lines: [''] }) + } + + return { + getCodeLineNumbers, + onCodeBlockCommit, + onCodeBlockInsertBelow, + onCalloutBlockCommit, + onCalloutBlockInsertBelow, + onToggleBlockCommit, + onToggleBlockInsertBelow + } +} diff --git a/docs/history.md b/docs/history.md index 8542b31..19a26f5 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,3 +1,7 @@ +## 2026-07-27 v1.11.99 — 공개 Markdown fenced 블록 편집 이벤트를 분리한다 + +코드·콜아웃·토글은 모두 여는 줄과 닫는 줄을 보존하면서 본문을 갱신하고 아래 문단으로 이탈하는 흐름을 공유한다. 이를 `ContentMarkdownRenderer` 안에 각각의 이벤트 함수로 남겨두면 라이브 편집 템플릿과 마크다운 줄 직렬화 책임이 계속 섞인다. `useContentMarkdownFencedBlocks`로 분리해 렌더러는 블록 컴포넌트 이벤트를 연결하고, fenced 블록 문법 보존과 다음 문단 포커스 준비는 composable에서 담당하게 한다. + ## 2026-07-27 v1.11.98 — 공개 Markdown 라이브 선택과 포커스 상태를 분리한다 라이브 편집 모드의 포커스 복원, Shift 범위 선택, 전체 선택, 선택 삭제, line focus/blur 이벤트는 본문 블록 렌더링보다 편집 세션 상태에 가깝다. 이를 `ContentMarkdownRenderer` 안에 두면 블록 렌더링 이벤트와 DOM 선택 중개가 섞여 파일을 줄여도 todo의 실제 범위가 보이지 않는다. `useContentMarkdownLiveSelection`으로 옮겨 렌더러는 블록별 편집 이벤트 연결에 집중하고, 남은 todo는 실제 블록 편집 도메인 분리로 좁힌다. diff --git a/docs/map.md b/docs/map.md index 365d01f..d77a1f5 100644 --- a/docs/map.md +++ b/docs/map.md @@ -30,6 +30,7 @@ | composables/useContentMarkdownLightbox.js | 공개 Markdown 본문 단일 이미지·갤러리 통합 라이트박스 상태·좌우 이동·ESC 닫기·키보드 이벤트 연결 관리 | | composables/useContentMarkdownImageDrag.js | 관리자 라이브 Markdown 본문의 단일 이미지·갤러리 드래그 상태, drop target 표시, 병합·삽입·분리·정렬 이벤트 중개 | | composables/useContentMarkdownLiveSelection.js | 관리자 라이브 Markdown 본문의 Selection Bridge, 포커스 복원, line focus/blur, 원문 편집 표시 상태 관리 | +| composables/useContentMarkdownFencedBlocks.js | 관리자 라이브 Markdown 코드·콜아웃·토글 fenced 블록 편집 이벤트와 아래 문단 삽입 상태 관리 | | composables/useAdminMediaThumbnailRegeneration.js | 관리자 미디어 화면 대표 이미지·페이지 커버·본문 이미지 재생성 가능 여부와 API 호출 상태 | | composables/usePublicSiteSettings.js | 공개 화면의 `/api/site-settings` 조회 key(`site-settings-public`)와 기본 설정값 통일 | @@ -137,6 +138,7 @@ | tests/useContentMarkdownLightbox.test.js | 공개 Markdown 라이트박스 composable의 열기·좌우 순환·ESC 닫기·키보드 이벤트 해제 단위 테스트 | | tests/useContentMarkdownImageDrag.test.js | 공개 Markdown 라이브 이미지·갤러리 드래그 composable의 병합·삽입·분리·정렬 단위 테스트 | | tests/useContentMarkdownLiveSelection.test.js | 공개 Markdown 라이브 선택 composable의 포커스 요청·예약 포커스 초기화·line focus/blur 단위 테스트 | +| tests/useContentMarkdownFencedBlocks.test.js | 공개 Markdown fenced 블록 composable의 코드·콜아웃·토글 아래 문단 삽입 단위 테스트 | | tests/markdown-block-parser.test.js | 공개 Markdown 본문 블록 파싱 단위 회귀 테스트 | | tests/markdown-fenced-meta.test.js | Markdown fenced 블록 시작·종료 줄 탐색, 본문 수집, 메타 파싱 단위 회귀 테스트 | | tests/markdown-quote.test.js | Markdown 인용 옵션 파싱과 편집 줄 변환 단위 회귀 테스트 | diff --git a/docs/spec.md b/docs/spec.md index 17f083c..728d88a 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -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` 계산으로 화면 폭에 맞는 파일을 우선 사용한다. 공개 본문 라이트박스 대상 목록은 `useContentMarkdownBlocks`에서 `lib/content-gallery-images.js` 기준으로 계산하고, 갤러리 행 분할은 `lib/content-gallery-images.js`에서 계산한다. 관리자 라이브 모드의 단일 이미지·갤러리 드래그 상태와 병합·삽입·분리·정렬 이벤트 중개는 `useContentMarkdownImageDrag`에서 관리한다. 라이브 모드의 선택 확장·전체 선택·선택 삭제·포커스 복원·line focus/blur 중개는 `useContentMarkdownLiveSelection`에서 관리한다. +- 본문 표시용 이미지는 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`에서 계산한다. 관리자 라이브 모드의 단일 이미지·갤러리 드래그 상태와 병합·삽입·분리·정렬 이벤트 중개는 `useContentMarkdownImageDrag`에서 관리한다. 라이브 모드의 선택 확장·전체 선택·선택 삭제·포커스 복원·line focus/blur 중개는 `useContentMarkdownLiveSelection`에서 관리한다. 코드·콜아웃·토글 fenced 블록의 라이브 편집 이벤트는 `useContentMarkdownFencedBlocks`에서 관리한다. - 라이트박스용 이미지는 2560px `thumbs/*-body-2560.webp`를 생성해 크게 보기 화면에서 우선 사용한다. 별도 원본 보기 UI는 제공하지 않으며, `lib/content-image-urls.js`가 파생 URL을 선택하고 파생 이미지 로드 실패 시에만 원본으로 fallback한다. - 기존 운영 볼륨에 생성된 `thumbs/*-body.webp`, `thumbs/*-lightbox.webp`는 레거시 본문 파생 이미지로 계속 인식해 관리자 미디어의 본문 이미지 탭과 사용처 연결을 유지한다. 새 저장·재생성·백필은 해상도형 파일명만 만든다. - 관리자 미디어의 원본 이미지 사용처는 원본 URL 직접 참조뿐 아니라 해당 원본에서 생성된 카드·페이지 커버·본문 파생 이미지 URL 사용처까지 함께 반영한다. 사용처 계산은 관리자 글 목록 요약 API와 분리된 미디어 참조 전용 조회로 게시물·페이지의 본문과 대표 이미지만 확인한다. 따라서 공개 본문이 `body-1600` 같은 파생 이미지를 사용하더라도 원본은 미사용 삭제 대상에서 제외되고, 본문 파생 이미지도 실제 본문 사용처가 있으면 미사용으로 분류하지 않는다. 원본이 실제로 미사용이라 삭제될 때는 연결된 카드·페이지 커버·본문 자동 생성 이미지도 함께 삭제한다. diff --git a/docs/todo.md b/docs/todo.md index f931b3f..1aef924 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -4,7 +4,7 @@ - 거대 컴포넌트 책임 분리 진행. - `components/admin/AdminMarkdownEditor.vue`: 미디어 모달, 슬래시 명령, 히스토리·선택 복원, 라이브/소스 전환 로직을 단계적으로 composable 또는 하위 컴포넌트로 분리. - - `components/content/ContentMarkdownRenderer.vue`: 남은 블록별 편집 이벤트(`onParagraph*`, `onList*`, `onQuote*`, `onCallout*`, `onToggle*`, `onCode*`)를 도메인별 helper/composable로 추가 분리. + - `components/content/ContentMarkdownRenderer.vue`: 남은 문단·목록·인용 편집 이벤트(`onParagraph*`, `onList*`, `onQuote*`)를 도메인별 helper/composable로 추가 분리. - 미디어 카탈로그 전환 코드 수명 정리. - 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토. - fallback은 운영 안정성에 필요하므로 제거 전 reconcile·로그·테스트 기준을 먼저 정리. diff --git a/docs/update.md b/docs/update.md index 9515686..7af2952 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 이력 +## v1.11.99 + +- 공개 Markdown 렌더러의 코드·콜아웃·토글 fenced 블록 편집 이벤트를 `useContentMarkdownFencedBlocks` composable로 분리. +- fenced 블록 composable의 코드·콜아웃·토글 아래 문단 삽입 단위 테스트 추가. +- v1.11 내부 개선 todo에서 완료된 fenced 블록 편집 이벤트 분리 범위를 제외하고 남은 블록 이벤트 범위를 재정리. + ## v1.11.98 - 공개 Markdown 렌더러의 라이브 선택·포커스 복원·line focus/blur·원문 편집 표시 상태를 `useContentMarkdownLiveSelection` composable로 분리. diff --git a/package-lock.json b/package-lock.json index 2f9d77c..ee1d4de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.11.98", + "version": "1.11.99", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.11.98", + "version": "1.11.99", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index e1fa023..71ae3f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.11.98", + "version": "1.11.99", "private": true, "type": "module", "imports": { diff --git a/tests/useContentMarkdownFencedBlocks.test.js b/tests/useContentMarkdownFencedBlocks.test.js new file mode 100644 index 0000000..cbcd47b --- /dev/null +++ b/tests/useContentMarkdownFencedBlocks.test.js @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { ref } from 'vue' +import { useContentMarkdownFencedBlocks } from '../composables/useContentMarkdownFencedBlocks.js' + +/** + * 테스트용 fenced 블록 편집 상태를 만든다. + * @param {string} contentText - Markdown 본문 + * @returns {{ calls: Object, pendingFocusPosition: import('vue').Ref, pendingFocusOffset: import('vue').Ref, state: Object }} + */ +const createFencedState = (contentText = '') => { + const calls = { + commits: [], + inserts: [] + } + const pendingFocusPosition = ref('auto') + const pendingFocusOffset = ref(null) + const state = useContentMarkdownFencedBlocks({ + content: ref(contentText), + interactive: ref(true), + pendingFocusPosition, + pendingFocusOffset, + commitInlineBlockLines: (block, replacementLines) => { + calls.commits.push({ block, replacementLines }) + }, + insertBelowBlock: (block, options) => { + calls.inserts.push({ block, options }) + } + }) + + return { + calls, + pendingFocusPosition, + pendingFocusOffset, + state + } +} + +test('fenced 코드 블록은 본문을 코드 펜스 줄로 저장하고 아래 문단을 삽입한다', () => { + const block = { + type: 'code', + codeLanguage: 'javascript', + codeShowLineNumbers: true, + text: 'a\nb', + meta: { startLine: 1, endLine: 4 } + } + const { calls, state } = createFencedState() + + assert.deepEqual(state.getCodeLineNumbers(block), [1, 2]) + + state.onCodeBlockInsertBelow(block, { value: 'console.log(1)' }) + + assert.deepEqual(calls.commits[0].replacementLines, [ + '```javascript line-numbers', + 'console.log(1)', + '```' + ]) + assert.deepEqual(calls.inserts[0], { block, options: { lines: [''] } }) +}) + +test('fenced 콜아웃 블록은 기존 여는 줄을 유지하고 다음 문단 포커스를 준비한다', () => { + const block = { + type: 'callout', + meta: { startLine: 0, endLine: 3 } + } + const { calls, pendingFocusOffset, pendingFocusPosition, state } = createFencedState([ + ':::callout bg=blue title=안내', + 'old', + ':::' + ].join('\n')) + + state.onCalloutBlockInsertBelow(block, { value: '새 본문\n둘째 줄' }) + + assert.deepEqual(calls.commits[0].replacementLines, [ + ':::callout bg=blue title=안내', + '새 본문', + '둘째 줄', + ':::' + ]) + assert.equal(pendingFocusPosition.value, 'start') + assert.equal(pendingFocusOffset.value, 0) + assert.deepEqual(calls.inserts[0], { block, options: { lines: [''] } }) +}) + +test('fenced 토글 블록은 제목과 기본 펼침 상태를 유지하고 다음 문단을 삽입한다', () => { + const block = { + type: 'toggle', + title: '자세히', + defaultOpen: true, + meta: { startLine: 0, endLine: 3 } + } + const { calls, pendingFocusOffset, pendingFocusPosition, state } = createFencedState() + + state.onToggleBlockInsertBelow(block, '토글 본문') + + assert.deepEqual(calls.commits[0].replacementLines, [ + ':::toggle open 자세히', + '토글 본문', + ':::' + ]) + assert.equal(pendingFocusPosition.value, 'start') + assert.equal(pendingFocusOffset.value, 0) + assert.deepEqual(calls.inserts[0], { block, options: { lines: [''] } }) +})