diff --git a/components/content/ContentMarkdownRenderer.vue b/components/content/ContentMarkdownRenderer.vue index a870265..d88205c 100644 --- a/components/content/ContentMarkdownRenderer.vue +++ b/components/content/ContentMarkdownRenderer.vue @@ -26,11 +26,9 @@ import { getAppendTextForMerge, getMergeJunctionDisplayOffset, hasListMarker, - hasQuoteMarker, isEmptyListMarkerLine, parseOrderedListMarker, - stripListMarker, - stripQuoteMarker + stripListMarker } from '../../lib/markdown-live-edit.js' import { buildCodeBlockLines, buildCodeFenceOpener, parseCodeFenceLine } from '../../lib/markdown-code-block.js' import { buildToggleBlockLines, parseToggleOpenerLine } from '../../lib/markdown-toggle.js' @@ -44,9 +42,17 @@ import { import { buildCalloutOpenerLine, CALLOUT_BACKGROUND_OPTIONS, - QUOTE_BACKGROUND_OPTIONS, parseCalloutOptions } from '../../lib/markdown-callout.js' +import { + formatQuoteLine, + getQuoteContentStartLine, + getQuoteLineBody, + getQuoteLineEntries, + isQuoteMarkerLine, + normalizeQuoteEditableValue, + parseQuoteOptions +} from '../../lib/markdown-quote.js' import { getInArticleAdAfterBlockIds } from '../../lib/content-in-article-ad.js' import { createHeadingIdFactory } from '../../lib/markdown-toc.js' import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue' @@ -188,53 +194,6 @@ const createBlock = (type = 'paragraph', text = '', level = null, id = '', optio */ const parseImageLine = (line) => parseImageMarkdownLine(line) -/** - * 인용 마커 줄인지 확인한다. - * @param {string} line - 마크다운 행 - * @returns {boolean} - */ -const isQuoteMarkerLine = (line) => { - const trimmed = String(line ?? '').trim() - return trimmed === '>' || /^>\s/.test(trimmed) -} - -/** - * 인용 마커를 제거한 본문을 반환한다. - * @param {string} line - 마크다운 행 - * @returns {string} 인용 본문 - */ -const getQuoteLineBody = (line) => String(line ?? '').trim().replace(/^>\s?/, '') - -/** - * 인용 옵션 줄을 파싱한다. - * @param {string} value - 인용 본문 줄 - * @returns {{ quoteBackground: string }|null} 인용 옵션 - */ -const parseQuoteOptions = (value) => { - const raw = String(value ?? '').trim() - const bracketMatch = raw.match(/^\[!(.+)\]$/) - const braceMatch = raw.match(/^\{(.+)\}$/) - const optionSource = bracketMatch?.[1] || braceMatch?.[1] || '' - - if (!optionSource) { - return null - } - - const tokens = optionSource.trim().split(/\s+/) - let quoteBackground = '' - - tokens.forEach((token) => { - const [key, rawOptionValue] = token.split('=') - const optionValue = String(rawOptionValue || '').trim() - - if (key?.toLowerCase() === 'bg' && QUOTE_BACKGROUND_OPTIONS.includes(optionValue)) { - quoteBackground = optionValue - } - }) - - return quoteBackground ? { quoteBackground } : null -} - /** * 불릿 목록 마커 줄인지 확인한다. * @param {string} line - 마크다운 행 @@ -1276,25 +1235,6 @@ const getBlockSourceLines = (block) => String(props.content || '').split('\n').s (block.meta.endLine ?? block.meta.startLine) + 1 ) -/** - * 인용 줄 마크다운을 만든다. - * @param {string} value - 편집 값 - * @param {boolean} raw - 원문 모드 여부 - * @returns {string} 마크다운 줄 - */ -const formatQuoteLine = (value, raw) => { - if (raw) { - if (!hasQuoteMarker(value)) { - return String(value ?? '').trim() - } - - return value - } - - const clean = stripQuoteMarker(value) - return clean ? `> ${clean}` : '> ' -} - /** * 목록 항목 마크다운 줄을 만든다. * @param {string} value - 편집 값 @@ -1353,52 +1293,6 @@ const createEmptyListMarkerLine = (block, itemIndex) => { return '- ' } -/** - * 인용 블록의 본문 시작 줄을 반환한다. - * @param {Object} block - 인용 블록 - * @returns {number} 본문 시작 줄 - */ -const getQuoteContentStartLine = (block) => { - if (typeof block.meta?.quoteContentStartLine === 'number') { - return block.meta.quoteContentStartLine - } - - return block.meta.startLine -} - -/** - * 인용 블록을 편집 가능한 줄 단위로 분리한다. - * @param {Object} block - 인용 블록 - * @returns {Array<{ text: string, sourceLine: number, sourceIndex: number }>} 줄 목록 - */ -const getQuoteLineEntries = (block) => { - const contentStartLine = getQuoteContentStartLine(block) - const endLine = block.meta.endLine ?? block.meta.startLine - const lineCount = Math.max(1, endLine - contentStartLine + 1) - const fromText = String(block.text ?? '').split('\n') - - while (fromText.length < lineCount) { - fromText.push('') - } - - return fromText.slice(0, lineCount).map((text, index) => ({ - text, - sourceLine: contentStartLine + index, - sourceIndex: contentStartLine + index - block.meta.startLine - })) -} - -/** - * IME·브라우저 자동 목록 보정으로 생긴 인용 접두를 정리한다. - * @param {string} value - 인용 본문 - * @returns {string} 정리된 본문 - */ -const normalizeQuoteEditableValue = (value) => { - return String(value ?? '').replace(/\r/g, '').split('\n').map((line) => { - return line.replace(/^>\.\s?/, '') - }).join('\n') -} - /** * 코드 블록 줄 번호 목록을 만든다. * @param {Object} block - 코드 블록 diff --git a/docs/map.md b/docs/map.md index 195973c..61b7e71 100644 --- a/docs/map.md +++ b/docs/map.md @@ -49,6 +49,7 @@ | lib/markdown-html-block.js | 게시물 본문 HTML 블록의 parser 기반 허용 태그·속성 sanitizer, 실행 URL·인라인 style 제거와 새 창 링크 rel 보정 | | lib/markdown-callout.js | 인용 막대 색상·콜아웃 배경색 옵션, 선언 줄 파싱·직렬화 | | lib/markdown-fenced-meta.js | 공개 Markdown fenced 블록 본문 수집과 북마크·회원가입·미디어 메타 파싱 | +| lib/markdown-quote.js | 공개 Markdown 인용 줄 판별·옵션 파싱·라이브 편집 줄 변환 | | lib/brand-color.js | 사이트 브랜드 컬러 기본값·hex 검증·정규화 | | lib/markdown-image.js | 이미지 마크다운 직렬화·파싱, 단독 이미지 URL 판별 | | lib/markdown-table.js | 공개 Markdown 표 행·구분선·정렬·블록 파싱 | @@ -109,6 +110,7 @@ | tests/markdown-html-block.test.js | HTML sanitizer 단위 회귀 테스트 | | tests/content-in-article-ad.test.js | 공개 본문 인아티클 광고 삽입 위치 계산 단위 회귀 테스트 | | tests/markdown-fenced-meta.test.js | Markdown fenced 블록 본문 수집과 메타 파싱 단위 회귀 테스트 | +| tests/markdown-quote.test.js | Markdown 인용 옵션 파싱과 편집 줄 변환 단위 회귀 테스트 | | tests/multipart-file-upload.test.js | multipart 스트리밍·제한 단위 회귀 테스트 | | tests/csp-report.test.js | CSP URL 최소화·payload 형식 단위 회귀 테스트 | | tests/media-catalog.test.js | 미디어 URL·역할·참조·LIKE 이스케이프 단위 회귀 테스트 | diff --git a/docs/update.md b/docs/update.md index 36c4444..902d507 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 이력 +## v1.11.51 + +- 공개 Markdown 렌더러의 인용 줄 판별·옵션 파싱·편집 줄 변환 helper를 `lib/markdown-quote.js`로 분리. +- 인용 옵션 선언과 라이브 편집 중 `>. ` 접두 정리 단위 테스트를 추가. +- `ContentMarkdownRenderer`는 인용 블록 생성과 편집 이벤트 연결만 담당하도록 정리. + ## v1.11.50 - 공개 Markdown 렌더러의 인아티클 광고 삽입 위치 계산을 `lib/content-in-article-ad.js`로 분리. diff --git a/lib/markdown-quote.js b/lib/markdown-quote.js new file mode 100644 index 0000000..5198724 --- /dev/null +++ b/lib/markdown-quote.js @@ -0,0 +1,114 @@ +import { QUOTE_BACKGROUND_OPTIONS } from './markdown-callout.js' +import { hasQuoteMarker, stripQuoteMarker } from './markdown-live-edit.js' + +/** + * 인용 마커 줄인지 확인한다. + * @param {string} line - 마크다운 행 + * @returns {boolean} 인용 마커 줄 여부 + */ +export const isQuoteMarkerLine = (line) => { + const trimmed = String(line ?? '').trim() + return trimmed === '>' || /^>\s/.test(trimmed) +} + +/** + * 인용 마커를 제거한 본문을 반환한다. + * @param {string} line - 마크다운 행 + * @returns {string} 인용 본문 + */ +export const getQuoteLineBody = (line) => String(line ?? '').trim().replace(/^>\s?/, '') + +/** + * 인용 옵션 줄을 파싱한다. + * @param {string} value - 인용 본문 줄 + * @returns {{ quoteBackground: string }|null} 인용 옵션 + */ +export const parseQuoteOptions = (value) => { + const raw = String(value ?? '').trim() + const bracketMatch = raw.match(/^\[!(.+)\]$/) + const braceMatch = raw.match(/^\{(.+)\}$/) + const optionSource = bracketMatch?.[1] || braceMatch?.[1] || '' + + if (!optionSource) { + return null + } + + const tokens = optionSource.trim().split(/\s+/) + let quoteBackground = '' + + tokens.forEach((token) => { + const [key, rawOptionValue] = token.split('=') + const optionValue = String(rawOptionValue || '').trim() + + if (key?.toLowerCase() === 'bg' && QUOTE_BACKGROUND_OPTIONS.includes(optionValue)) { + quoteBackground = optionValue + } + }) + + return quoteBackground ? { quoteBackground } : null +} + +/** + * 인용 줄 마크다운을 만든다. + * @param {string} value - 편집 값 + * @param {boolean} raw - 원문 모드 여부 + * @returns {string} 마크다운 줄 + */ +export const formatQuoteLine = (value, raw) => { + if (raw) { + if (!hasQuoteMarker(value)) { + return String(value ?? '').trim() + } + + return value + } + + const clean = stripQuoteMarker(value) + return clean ? `> ${clean}` : '> ' +} + +/** + * 인용 블록의 본문 시작 줄을 반환한다. + * @param {Object} block - 인용 블록 + * @returns {number} 본문 시작 줄 + */ +export const getQuoteContentStartLine = (block) => { + if (typeof block?.meta?.quoteContentStartLine === 'number') { + return block.meta.quoteContentStartLine + } + + return block?.meta?.startLine ?? 0 +} + +/** + * 인용 블록을 편집 가능한 줄 단위로 분리한다. + * @param {Object} block - 인용 블록 + * @returns {Array<{ text: string, sourceLine: number, sourceIndex: number }>} 줄 목록 + */ +export const getQuoteLineEntries = (block) => { + const contentStartLine = getQuoteContentStartLine(block) + const endLine = block?.meta?.endLine ?? block?.meta?.startLine ?? contentStartLine + const lineCount = Math.max(1, endLine - contentStartLine + 1) + const fromText = String(block?.text ?? '').split('\n') + + while (fromText.length < lineCount) { + fromText.push('') + } + + return fromText.slice(0, lineCount).map((text, index) => ({ + text, + sourceLine: contentStartLine + index, + sourceIndex: contentStartLine + index - (block?.meta?.startLine ?? contentStartLine) + })) +} + +/** + * IME·브라우저 자동 목록 보정으로 생긴 인용 접두를 정리한다. + * @param {string} value - 인용 본문 + * @returns {string} 정리된 본문 + */ +export const normalizeQuoteEditableValue = (value) => { + return String(value ?? '').replace(/\r/g, '').split('\n').map((line) => { + return line.replace(/^>\.\s?/, '') + }).join('\n') +} diff --git a/package-lock.json b/package-lock.json index 09c155d..0a672cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.11.50", + "version": "1.11.51", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.11.50", + "version": "1.11.51", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index ee16dec..e47b12f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.11.50", + "version": "1.11.51", "private": true, "type": "module", "imports": { diff --git a/tests/markdown-quote.test.js b/tests/markdown-quote.test.js new file mode 100644 index 0000000..0b5cf50 --- /dev/null +++ b/tests/markdown-quote.test.js @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + formatQuoteLine, + getQuoteContentStartLine, + getQuoteLineBody, + getQuoteLineEntries, + isQuoteMarkerLine, + normalizeQuoteEditableValue, + parseQuoteOptions +} from '../lib/markdown-quote.js' + +test('인용 마커 줄과 본문을 판별한다', () => { + assert.equal(isQuoteMarkerLine('>'), true) + assert.equal(isQuoteMarkerLine('> 본문'), true) + assert.equal(isQuoteMarkerLine('본문'), false) + assert.equal(getQuoteLineBody('> 본문'), '본문') +}) + +test('인용 옵션은 대괄호와 중괄호 선언을 모두 파싱한다', () => { + assert.deepEqual(parseQuoteOptions('[!bg=blue]'), { quoteBackground: 'blue' }) + assert.deepEqual(parseQuoteOptions('{bg=purple}'), { quoteBackground: 'purple' }) + assert.equal(parseQuoteOptions('{bg=unknown}'), null) +}) + +test('인용 줄 직렬화는 원문 모드와 편집 모드를 구분한다', () => { + assert.equal(formatQuoteLine('본문', false), '> 본문') + assert.equal(formatQuoteLine('', false), '> ') + assert.equal(formatQuoteLine('> 원문', true), '> 원문') + assert.equal(formatQuoteLine('원문', true), '원문') +}) + +test('인용 블록 본문 줄 위치를 옵션 줄 이후로 계산한다', () => { + const block = { + text: '첫 줄\n둘째 줄', + meta: { + startLine: 10, + endLine: 12, + quoteContentStartLine: 11 + } + } + + assert.equal(getQuoteContentStartLine(block), 11) + assert.deepEqual(getQuoteLineEntries(block), [ + { + text: '첫 줄', + sourceLine: 11, + sourceIndex: 1 + }, + { + text: '둘째 줄', + sourceLine: 12, + sourceIndex: 2 + } + ]) +}) + +test('인용 편집 중 브라우저가 만든 >. 접두를 정리한다', () => { + assert.equal(normalizeQuoteEditableValue('>. 발타자르\n> 정상'), '발타자르\n> 정상') +})