diff --git a/components/content/ContentMarkdownRenderer.vue b/components/content/ContentMarkdownRenderer.vue index 1df2cfc..4734a0f 100644 --- a/components/content/ContentMarkdownRenderer.vue +++ b/components/content/ContentMarkdownRenderer.vue @@ -34,6 +34,7 @@ import { } from '../../lib/markdown-live-edit.js' import { buildCodeBlockLines, buildCodeFenceOpener, parseCodeFenceLine } from '../../lib/markdown-code-block.js' import { buildToggleBlockLines, parseToggleOpenerLine } from '../../lib/markdown-toggle.js' +import { parseMarkdownTableBlock } from '../../lib/markdown-table.js' import { buildCalloutOpenerLine, CALLOUT_BACKGROUND_OPTIONS, @@ -475,90 +476,6 @@ const parseMediaMeta = (raw) => { return meta } -/** - * 마크다운 표 행 후보인지 확인한다. - * @param {string} line - 원본 줄 - * @returns {boolean} 표 행 후보 여부 - */ -const isMarkdownTableRow = (line) => { - const value = String(line || '').trim() - return value.startsWith('|') && value.endsWith('|') && value.split('|').length >= 4 -} - -/** - * 마크다운 표 구분선 행인지 확인한다. - * @param {string} line - 원본 줄 - * @returns {boolean} 구분선 여부 - */ -const isMarkdownTableSeparator = (line) => { - if (!isMarkdownTableRow(line)) { - return false - } - - return splitMarkdownTableRow(line).every((cell) => /^:?-{3,}:?$/.test(cell.trim())) -} - -/** - * 마크다운 표 행을 셀 배열로 나눈다. - * @param {string} line - 원본 줄 - * @returns {string[]} 셀 목록 - */ -const splitMarkdownTableRow = (line) => String(line || '') - .trim() - .replace(/^\|/, '') - .replace(/\|$/, '') - .split('|') - .map((cell) => cell.trim().replace(/\\\|/g, '|')) - -/** - * 표 정렬 행에서 셀 정렬 값을 파싱한다. - * @param {string} cell - 구분선 셀 - * @returns {'left'|'center'|'right'} 정렬 값 - */ -const parseMarkdownTableAlignment = (cell) => { - const value = String(cell || '').trim() - - if (value.startsWith(':') && value.endsWith(':')) { - return 'center' - } - - if (value.endsWith(':')) { - return 'right' - } - - return 'left' -} - -/** - * 마크다운 표 블록을 파싱한다. - * @param {string[]} lines - 전체 마크다운 줄 - * @param {number} startIndex - 표 시작 후보 줄 - * @returns {{headers: string[], alignments: string[], rows: string[][], endLine: number, nextIndex: number}|null} 표 메타 - */ -const parseMarkdownTableBlock = (lines, startIndex) => { - if (!isMarkdownTableRow(lines[startIndex]) || !isMarkdownTableSeparator(lines[startIndex + 1])) { - return null - } - - const headers = splitMarkdownTableRow(lines[startIndex]) - const alignments = splitMarkdownTableRow(lines[startIndex + 1]).map(parseMarkdownTableAlignment) - const rows = [] - let index = startIndex + 2 - - while (index < lines.length && isMarkdownTableRow(lines[index]) && !isMarkdownTableSeparator(lines[index])) { - rows.push(splitMarkdownTableRow(lines[index])) - index += 1 - } - - return { - headers, - alignments, - rows, - endLine: index - 1, - nextIndex: index - } -} - /** * 마크다운 문자열을 렌더링용 블록 목록으로 변환 * @param {string} markdown - 마크다운 문자열 diff --git a/docs/map.md b/docs/map.md index c0720fc..e269748 100644 --- a/docs/map.md +++ b/docs/map.md @@ -49,6 +49,7 @@ | lib/markdown-callout.js | 인용 막대 색상·콜아웃 배경색 옵션, 선언 줄 파싱·직렬화 | | lib/brand-color.js | 사이트 브랜드 컬러 기본값·hex 검증·정규화 | | lib/markdown-image.js | 이미지 마크다운 직렬화·파싱, 단독 이미지 URL 판별 | +| lib/markdown-table.js | 공개 Markdown 표 행·구분선·정렬·블록 파싱 | | lib/markdown-toc.js | 공개 게시글 TOC용 H1~H3 제목 추출과 앵커 ID 생성 | | lib/markdown-slash-commands.js | 관리자 Markdown-first 에디터 슬래시 명령 목록과 삽입 줄 정의 | | lib/markdown-inline.js | 공개·관리자 공통 인라인 마크다운 파서, 링크·굵게·기울임·인라인 코드·첨자·인라인 색상 토큰(`::color[텍스트]`) 변환, 링크·굵게·기울임·색상 내부 중첩 인라인 문법과 `
` 줄바꿈 처리, 라이브 멀티라인 본문의 기본 불릿/번호 목록 HTML 변환과 contenteditable 목록 마커 기준 커서 offset 계산·직렬화 | @@ -107,6 +108,7 @@ | tests/multipart-file-upload.test.js | multipart 스트리밍·제한 단위 회귀 테스트 | | tests/csp-report.test.js | CSP URL 최소화·payload 형식 단위 회귀 테스트 | | tests/media-catalog.test.js | 미디어 URL·역할·참조·LIKE 이스케이프 단위 회귀 테스트 | +| tests/markdown-table.test.js | Markdown 표 행·정렬·블록 파싱 단위 회귀 테스트 | | tests/admin-pagination.test.js | 관리자 페이지네이션 페이지 보정·ellipsis 항목 단위 테스트 | | tests/playwright/api/core-api.spec.js | 인증·부분 설정 저장·공개 목록·CSP·미디어 reconcile/fallback·세션 무효화 API 통합 10건 | | tests/playwright/e2e/admin-guard-and-settings.spec.js | 설정 카드 독립 저장과 미저장 이탈 취소 브라우저 E2E 2건 | diff --git a/docs/update.md b/docs/update.md index c4c6a54..78be467 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 이력 +## v1.11.48 + +- 공개 Markdown 렌더러의 표 행·구분선·정렬·블록 파싱 helper를 `lib/markdown-table.js`로 분리. +- 표 파싱 단위 테스트를 추가해 정렬과 이스케이프된 파이프 문자 처리를 회귀 확인. +- `ContentMarkdownRenderer`는 표 렌더링 데이터 소비에 집중하도록 내부 파싱 책임을 축소. + ## v1.11.47 - 관리자 미디어 화면의 대표 이미지·페이지 커버·본문 이미지 재생성 상태와 API 호출을 `useAdminMediaThumbnailRegeneration` composable로 분리. diff --git a/lib/markdown-table.js b/lib/markdown-table.js new file mode 100644 index 0000000..6002eb2 --- /dev/null +++ b/lib/markdown-table.js @@ -0,0 +1,112 @@ +/** + * 마크다운 표 행 후보인지 확인한다. + * @param {string} line - 원본 줄 + * @returns {boolean} 표 행 후보 여부 + */ +export const isMarkdownTableRow = (line) => { + const value = String(line || '').trim() + return value.startsWith('|') && value.endsWith('|') && value.split('|').length >= 4 +} + +/** + * 마크다운 표 행을 셀 배열로 나눈다. + * @param {string} line - 원본 줄 + * @returns {string[]} 셀 목록 + */ +export const splitMarkdownTableRow = (line) => { + const value = String(line || '').trim().replace(/^\|/, '').replace(/\|$/, '') + const cells = [] + let cell = '' + let escaped = false + + for (const character of value) { + if (escaped) { + cell += character === '|' ? '|' : `\\${character}` + escaped = false + continue + } + + if (character === '\\') { + escaped = true + continue + } + + if (character === '|') { + cells.push(cell.trim()) + cell = '' + continue + } + + cell += character + } + + if (escaped) { + cell += '\\' + } + + cells.push(cell.trim()) + + return cells +} + +/** + * 마크다운 표 구분선 행인지 확인한다. + * @param {string} line - 원본 줄 + * @returns {boolean} 구분선 여부 + */ +export const isMarkdownTableSeparator = (line) => { + if (!isMarkdownTableRow(line)) { + return false + } + + return splitMarkdownTableRow(line).every((cell) => /^:?-{3,}:?$/.test(cell.trim())) +} + +/** + * 표 정렬 행에서 셀 정렬 값을 파싱한다. + * @param {string} cell - 구분선 셀 + * @returns {'left'|'center'|'right'} 정렬 값 + */ +export const parseMarkdownTableAlignment = (cell) => { + const value = String(cell || '').trim() + + if (value.startsWith(':') && value.endsWith(':')) { + return 'center' + } + + if (value.endsWith(':')) { + return 'right' + } + + return 'left' +} + +/** + * 마크다운 표 블록을 파싱한다. + * @param {string[]} lines - 전체 마크다운 줄 + * @param {number} startIndex - 표 시작 후보 줄 + * @returns {{headers: string[], alignments: string[], rows: string[][], endLine: number, nextIndex: number}|null} 표 메타 + */ +export const parseMarkdownTableBlock = (lines, startIndex) => { + if (!isMarkdownTableRow(lines[startIndex]) || !isMarkdownTableSeparator(lines[startIndex + 1])) { + return null + } + + const headers = splitMarkdownTableRow(lines[startIndex]) + const alignments = splitMarkdownTableRow(lines[startIndex + 1]).map(parseMarkdownTableAlignment) + const rows = [] + let index = startIndex + 2 + + while (index < lines.length && isMarkdownTableRow(lines[index]) && !isMarkdownTableSeparator(lines[index])) { + rows.push(splitMarkdownTableRow(lines[index])) + index += 1 + } + + return { + headers, + alignments, + rows, + endLine: index - 1, + nextIndex: index + } +} diff --git a/package-lock.json b/package-lock.json index f408050..3595dc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.11.47", + "version": "1.11.48", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.11.47", + "version": "1.11.48", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index cd55e28..875387d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.11.47", + "version": "1.11.48", "private": true, "type": "module", "imports": { diff --git a/tests/markdown-table.test.js b/tests/markdown-table.test.js new file mode 100644 index 0000000..e9d0507 --- /dev/null +++ b/tests/markdown-table.test.js @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + isMarkdownTableRow, + isMarkdownTableSeparator, + parseMarkdownTableAlignment, + parseMarkdownTableBlock, + splitMarkdownTableRow +} from '../lib/markdown-table.js' + +test('마크다운 표 행과 구분선을 판별한다', () => { + assert.equal(isMarkdownTableRow('| 제목 | 값 |'), true) + assert.equal(isMarkdownTableRow('제목 | 값'), false) + assert.equal(isMarkdownTableSeparator('| --- | :---: | ---: |'), true) + assert.equal(isMarkdownTableSeparator('| 제목 | 값 |'), false) +}) + +test('마크다운 표 셀과 정렬 값을 파싱한다', () => { + assert.deepEqual(splitMarkdownTableRow('| A | B\\|C |'), ['A', 'B|C']) + assert.equal(parseMarkdownTableAlignment('---'), 'left') + assert.equal(parseMarkdownTableAlignment(':---:'), 'center') + assert.equal(parseMarkdownTableAlignment('---:'), 'right') +}) + +test('마크다운 표 블록을 메타 정보로 변환한다', () => { + assert.deepEqual(parseMarkdownTableBlock([ + '| 이름 | 수량 | 비고 |', + '| :--- | ---: | :---: |', + '| 사과 | 2 | 빨강 |', + '| 배 | 10 | 노랑 |', + '', + '다음 문단' + ], 0), { + headers: ['이름', '수량', '비고'], + alignments: ['left', 'right', 'center'], + rows: [ + ['사과', '2', '빨강'], + ['배', '10', '노랑'] + ], + endLine: 3, + nextIndex: 4 + }) +}) + +test('구분선이 없으면 표 블록으로 처리하지 않는다', () => { + assert.equal(parseMarkdownTableBlock([ + '| 이름 | 수량 |', + '| 사과 | 2 |' + ], 0), null) +})