마크다운 표 파싱 로직 분리

This commit is contained in:
2026-07-24 14:00:04 +09:00
parent 164bc2131f
commit f996506a15
7 changed files with 174 additions and 87 deletions

View File

@@ -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 - 마크다운 문자열

View File

@@ -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[텍스트]`) 변환, 링크·굵게·기울임·색상 내부 중첩 인라인 문법과 `<br>` 줄바꿈 처리, 라이브 멀티라인 본문의 기본 불릿/번호 목록 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건 |

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v1.11.48
- 공개 Markdown 렌더러의 표 행·구분선·정렬·블록 파싱 helper를 `lib/markdown-table.js`로 분리.
- 표 파싱 단위 테스트를 추가해 정렬과 이스케이프된 파이프 문자 처리를 회귀 확인.
- `ContentMarkdownRenderer`는 표 렌더링 데이터 소비에 집중하도록 내부 파싱 책임을 축소.
## v1.11.47
- 관리자 미디어 화면의 대표 이미지·페이지 커버·본문 이미지 재생성 상태와 API 호출을 `useAdminMediaThumbnailRegeneration` composable로 분리.

112
lib/markdown-table.js Normal file
View File

@@ -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
}
}

4
package-lock.json generated
View File

@@ -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",

View File

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

View File

@@ -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)
})