글쓰기 태그 제한과 표 기능 추가

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-09 17:10:16 +09:00
parent ed30926250
commit 95d234a625
24 changed files with 560 additions and 54 deletions

View File

@@ -467,6 +467,90 @@ 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 - 마크다운 문자열
@@ -697,6 +781,24 @@ const parseMarkdownBlocks = (markdown) => {
continue
}
const tableBlock = parseMarkdownTableBlock(lines, index)
if (tableBlock) {
const startLine = index
blocks.push(attachSourceRange(
createBlock('table', '', null, `block-${blocks.length}`, {
meta: {
headers: tableBlock.headers,
alignments: tableBlock.alignments,
rows: tableBlock.rows
}
}),
startLine,
tableBlock.endLine
))
index = tableBlock.nextIndex
continue
}
if (isQuoteMarkerLine(line)) {
const startLine = index
const rawQuoteLines = []
@@ -1283,6 +1385,23 @@ const getHeadingEditableClass = (level) => {
return `${base} text-[clamp(0.9rem,0.875rem+0.1vw,1rem)]`
}
/**
* 표 셀 정렬 클래스를 반환한다.
* @param {string} alignment - 정렬 값
* @returns {string} Tailwind 정렬 클래스
*/
const getTableCellAlignClass = (alignment) => {
if (alignment === 'center') {
return 'text-center'
}
if (alignment === 'right') {
return 'text-right'
}
return 'text-left'
}
/**
* 제목 블록 마크다운 줄을 만든다.
* @param {number} level - 제목 레벨
@@ -3332,6 +3451,55 @@ onBeforeUnmount(() => {
</figure>
</div>
</div>
<div
v-else-if="block.type === 'table'"
class="content-markdown-renderer__table-wrap my-6 overflow-x-auto"
:data-source-line="block.meta.startLine"
:data-source-line-end="block.meta.endLine"
>
<table class="content-markdown-renderer__table w-full min-w-[520px] border-collapse text-sm text-[var(--site-text)]">
<thead>
<tr>
<th
v-for="(header, headerIndex) in block.meta.headers"
:key="`${block.id}-table-head-${headerIndex}`"
class="border border-line bg-[color-mix(in_srgb,var(--site-text)_6%,transparent)] px-3 py-2 font-semibold"
:class="getTableCellAlignClass(block.meta.alignments?.[headerIndex])"
>
<template v-for="(segment, segmentIndex) in parseInlineSegments(header)" :key="`${block.id}-table-head-${headerIndex}-${segmentIndex}`">
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
<sub v-else-if="segment.type === 'subscript'">{{ segment.text }}</sub>
<sup v-else-if="segment.type === 'superscript'">{{ segment.text }}</sup>
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
<template v-else>{{ segment.text }}</template>
</template>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in block.meta.rows" :key="`${block.id}-table-row-${rowIndex}`">
<td
v-for="(_, cellIndex) in block.meta.headers"
:key="`${block.id}-table-cell-${rowIndex}-${cellIndex}`"
class="border border-line px-3 py-2 align-top"
:class="getTableCellAlignClass(block.meta.alignments?.[cellIndex])"
>
<template v-for="(segment, segmentIndex) in parseInlineSegments(row[cellIndex] || '')" :key="`${block.id}-table-cell-${rowIndex}-${cellIndex}-${segmentIndex}`">
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
<sub v-else-if="segment.type === 'subscript'">{{ segment.text }}</sub>
<sup v-else-if="segment.type === 'superscript'">{{ segment.text }}</sup>
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
<template v-else>{{ segment.text }}</template>
</template>
</td>
</tr>
</tbody>
</table>
</div>
<ContentMarkdownCodeBlockEditor
v-else-if="block.type === 'code' && interactive"
:language="block.codeLanguage"