마크다운 fenced 메타 파싱 분리

This commit is contained in:
2026-07-24 14:04:39 +09:00
parent f996506a15
commit d764865220
7 changed files with 289 additions and 176 deletions

View File

@@ -35,6 +35,12 @@ import {
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 {
collectFencedLines,
parseBookmarkMeta,
parseMediaMeta,
parseSignupMeta
} from '../../lib/markdown-fenced-meta.js'
import {
buildCalloutOpenerLine,
CALLOUT_BACKGROUND_OPTIONS,
@@ -303,179 +309,6 @@ const isStandaloneImageUrlLine = (line) => isStandaloneUrlLine(line) && isImageU
*/
const getSpacerHeightClass = (block) => block.meta?.legacy ? 'h-6' : 'h-8'
/**
* 닫힘 표식까지의 행 목록을 반환
* @param {Array<string>} lines - 전체 마크다운 행
* @param {number} startIndex - 본문 시작 인덱스
* @returns {{contentLines: Array<string>, endLine: number, nextIndex: number}} 블록 본문과 다음 인덱스
*/
const collectFencedLines = (lines, startIndex) => {
const contentLines = []
let index = startIndex
while (index < lines.length && lines[index].trim() !== ':::') {
contentLines.push(lines[index])
index += 1
}
return {
contentLines,
endLine: index < lines.length ? index : Math.max(startIndex - 1, 0),
nextIndex: index < lines.length ? index + 1 : lines.length
}
}
/**
* 북마크 fenced 블록 본문에서 URL·제목·설명·썸네일을 파싱한다.
* @param {string} raw - fenced 내부 텍스트
* @returns {{url: string, title: string, description: string, thumbnail: string}} 북마크 메타
*/
const parseBookmarkMeta = (raw) => {
const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean)
const meta = {
url: '',
title: '',
description: '',
thumbnail: ''
}
for (const line of lines) {
const kv = line.match(/^(\w+)=(.*)$/)
if (kv) {
const key = kv[1].toLowerCase()
const val = kv[2].trim()
if (key === 'url') {
meta.url = val
} else if (key === 'title') {
meta.title = val
} else if (key === 'description' || key === 'desc') {
meta.description = val
} else if (key === 'thumbnail' || key === 'image') {
meta.thumbnail = val
}
continue
}
if (!meta.url && /^https?:\/\//i.test(line)) {
meta.url = line
continue
}
if (meta.url && !meta.title) {
meta.title = line
continue
}
if (meta.url && meta.title && !meta.description) {
meta.description = line
}
}
return meta
}
/**
* 회원가입(CTA) fenced 블록 본문에서 표시 문구를 파싱한다.
* @param {string} raw - fenced 내부 텍스트
* @returns {{title: string, description: string, button: string, placeholder: string}} CTA 메타
*/
const parseSignupMeta = (raw) => {
const meta = {
title: '뉴스레터에 가입하세요',
description: '새 글이 올라오면 받아보실 수 있어요.',
button: '구독하기',
placeholder: 'you@example.com'
}
const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean)
for (const line of lines) {
const kv = line.match(/^(\w+)=(.*)$/)
if (!kv) {
continue
}
const key = kv[1].toLowerCase()
const val = kv[2].trim()
if (key === 'title') {
meta.title = val
} else if (key === 'description' || key === 'desc') {
meta.description = val
} else if (key === 'button') {
meta.button = val
} else if (key === 'placeholder') {
meta.placeholder = val
}
}
return meta
}
/**
* 미디어 fenced 블록 본문에서 URL과 표시 메타를 파싱한다.
* @param {string} raw - fenced 내부 텍스트
* @returns {{url: string, title: string, description: string, poster: string, caption: string, fileName: string, size: string}} 미디어 메타
*/
const parseMediaMeta = (raw) => {
const meta = {
url: '',
title: '',
description: '',
poster: '',
caption: '',
fileName: '',
size: ''
}
const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean)
for (const line of lines) {
const kv = line.match(/^(\w+)=(.*)$/)
if (kv) {
const key = kv[1].toLowerCase()
const val = kv[2].trim()
if (key === 'url' || key === 'src') {
meta.url = val
} else if (key === 'title') {
meta.title = val
} else if (key === 'description' || key === 'desc') {
meta.description = val
} else if (key === 'poster' || key === 'thumbnail') {
meta.poster = val
} else if (key === 'caption') {
meta.caption = val
} else if (key === 'name' || key === 'filename' || key === 'file') {
meta.fileName = val
} else if (key === 'size') {
meta.size = val
}
continue
}
if (!meta.url && (/^https?:\/\//i.test(line) || line.startsWith('/'))) {
meta.url = line
continue
}
if (meta.url && !meta.title) {
meta.title = line
continue
}
if (meta.url && meta.title && !meta.description) {
meta.description = line
}
}
return meta
}
/**
* 마크다운 문자열을 렌더링용 블록 목록으로 변환
* @param {string} markdown - 마크다운 문자열

View File

@@ -47,6 +47,7 @@
| lib/markdown-iframe-embed.js | 게시물 본문 iframe 블록의 코드 판별과 안전 속성 추출 |
| lib/markdown-html-block.js | 게시물 본문 HTML 블록의 parser 기반 허용 태그·속성 sanitizer, 실행 URL·인라인 style 제거와 새 창 링크 rel 보정 |
| lib/markdown-callout.js | 인용 막대 색상·콜아웃 배경색 옵션, 선언 줄 파싱·직렬화 |
| lib/markdown-fenced-meta.js | 공개 Markdown fenced 블록 본문 수집과 북마크·회원가입·미디어 메타 파싱 |
| lib/brand-color.js | 사이트 브랜드 컬러 기본값·hex 검증·정규화 |
| lib/markdown-image.js | 이미지 마크다운 직렬화·파싱, 단독 이미지 URL 판별 |
| lib/markdown-table.js | 공개 Markdown 표 행·구분선·정렬·블록 파싱 |
@@ -105,6 +106,7 @@
| playwright.config.js | Playwright API/E2E 프로젝트, 단일 worker, 전용 Nuxt 서버·trace 설정 |
| docker-compose.test.yml | 실행마다 생성·폐기하는 PostgreSQL 16 테스트 DB·볼륨·네트워크 |
| tests/markdown-html-block.test.js | HTML sanitizer 단위 회귀 테스트 |
| tests/markdown-fenced-meta.test.js | Markdown fenced 블록 본문 수집과 메타 파싱 단위 회귀 테스트 |
| tests/multipart-file-upload.test.js | multipart 스트리밍·제한 단위 회귀 테스트 |
| tests/csp-report.test.js | CSP URL 최소화·payload 형식 단위 회귀 테스트 |
| tests/media-catalog.test.js | 미디어 URL·역할·참조·LIKE 이스케이프 단위 회귀 테스트 |

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v1.11.49
- 공개 Markdown 렌더러의 fenced 블록 본문 수집과 북마크·회원가입·미디어 메타 파싱을 `lib/markdown-fenced-meta.js`로 분리.
- fenced 블록 닫힘 표식 처리와 메타 필드 파싱 단위 테스트를 추가.
- `ContentMarkdownRenderer`는 fenced 블록 파싱 결과를 블록 데이터로 연결하는 책임만 남기도록 정리.
## v1.11.48
- 공개 Markdown 렌더러의 표 행·구분선·정렬·블록 파싱 helper를 `lib/markdown-table.js`로 분리.

172
lib/markdown-fenced-meta.js Normal file
View File

@@ -0,0 +1,172 @@
/**
* 닫힘 표식까지의 행 목록을 반환한다.
* @param {Array<string>} lines - 전체 마크다운 행
* @param {number} startIndex - 본문 시작 인덱스
* @returns {{contentLines: Array<string>, endLine: number, nextIndex: number}} 블록 본문과 다음 인덱스
*/
export const collectFencedLines = (lines, startIndex) => {
const contentLines = []
let index = startIndex
while (index < lines.length && lines[index].trim() !== ':::') {
contentLines.push(lines[index])
index += 1
}
return {
contentLines,
endLine: index < lines.length ? index : Math.max(lines.length - 1, startIndex - 1, 0),
nextIndex: index < lines.length ? index + 1 : lines.length
}
}
/**
* 북마크 fenced 블록 본문에서 URL·제목·설명·썸네일을 파싱한다.
* @param {string} raw - fenced 내부 텍스트
* @returns {{url: string, title: string, description: string, thumbnail: string}} 북마크 메타
*/
export const parseBookmarkMeta = (raw) => {
const lines = raw.split('\n').map((line) => line.trim()).filter(Boolean)
const meta = {
url: '',
title: '',
description: '',
thumbnail: ''
}
for (const line of lines) {
const kv = line.match(/^(\w+)=(.*)$/)
if (kv) {
const key = kv[1].toLowerCase()
const value = kv[2].trim()
if (key === 'url') {
meta.url = value
} else if (key === 'title') {
meta.title = value
} else if (key === 'description' || key === 'desc') {
meta.description = value
} else if (key === 'thumbnail' || key === 'image') {
meta.thumbnail = value
}
continue
}
if (!meta.url && /^https?:\/\//i.test(line)) {
meta.url = line
continue
}
if (meta.url && !meta.title) {
meta.title = line
continue
}
if (meta.url && meta.title && !meta.description) {
meta.description = line
}
}
return meta
}
/**
* 회원가입(CTA) fenced 블록 본문에서 표시 문구를 파싱한다.
* @param {string} raw - fenced 내부 텍스트
* @returns {{title: string, description: string, button: string, placeholder: string}} CTA 메타
*/
export const parseSignupMeta = (raw) => {
const meta = {
title: '뉴스레터에 가입하세요',
description: '새 글이 올라오면 받아보실 수 있어요.',
button: '구독하기',
placeholder: 'you@example.com'
}
const lines = raw.split('\n').map((line) => line.trim()).filter(Boolean)
for (const line of lines) {
const kv = line.match(/^(\w+)=(.*)$/)
if (!kv) {
continue
}
const key = kv[1].toLowerCase()
const value = kv[2].trim()
if (key === 'title') {
meta.title = value
} else if (key === 'description' || key === 'desc') {
meta.description = value
} else if (key === 'button') {
meta.button = value
} else if (key === 'placeholder') {
meta.placeholder = value
}
}
return meta
}
/**
* 미디어 fenced 블록 본문에서 URL과 표시 메타를 파싱한다.
* @param {string} raw - fenced 내부 텍스트
* @returns {{url: string, title: string, description: string, poster: string, caption: string, fileName: string, size: string}} 미디어 메타
*/
export const parseMediaMeta = (raw) => {
const meta = {
url: '',
title: '',
description: '',
poster: '',
caption: '',
fileName: '',
size: ''
}
const lines = raw.split('\n').map((line) => line.trim()).filter(Boolean)
for (const line of lines) {
const kv = line.match(/^(\w+)=(.*)$/)
if (kv) {
const key = kv[1].toLowerCase()
const value = kv[2].trim()
if (key === 'url' || key === 'src') {
meta.url = value
} else if (key === 'title') {
meta.title = value
} else if (key === 'description' || key === 'desc') {
meta.description = value
} else if (key === 'poster' || key === 'thumbnail') {
meta.poster = value
} else if (key === 'caption') {
meta.caption = value
} else if (key === 'name' || key === 'filename' || key === 'file') {
meta.fileName = value
} else if (key === 'size') {
meta.size = value
}
continue
}
if (!meta.url && (/^https?:\/\//i.test(line) || line.startsWith('/'))) {
meta.url = line
continue
}
if (meta.url && !meta.title) {
meta.title = line
continue
}
if (meta.url && meta.title && !meta.description) {
meta.description = line
}
}
return meta
}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.11.48",
"version": "1.11.49",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.11.48",
"version": "1.11.49",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

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

View File

@@ -0,0 +1,100 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
collectFencedLines,
parseBookmarkMeta,
parseMediaMeta,
parseSignupMeta
} from '../lib/markdown-fenced-meta.js'
test('fenced 블록 닫힘 표식까지의 본문과 다음 줄을 수집한다', () => {
assert.deepEqual(collectFencedLines([
':::bookmark',
'https://example.com',
'Example',
':::',
'다음 문단'
], 1), {
contentLines: ['https://example.com', 'Example'],
endLine: 3,
nextIndex: 4
})
})
test('닫힘 표식이 없는 fenced 블록도 마지막 본문 범위를 반환한다', () => {
assert.deepEqual(collectFencedLines([
':::video',
'/uploads/video.mp4'
], 1), {
contentLines: ['/uploads/video.mp4'],
endLine: 1,
nextIndex: 2
})
})
test('북마크 메타는 key=value와 순차 본문을 모두 파싱한다', () => {
assert.deepEqual(parseBookmarkMeta([
'https://example.com/post',
'예시 제목',
'예시 설명'
].join('\n')), {
url: 'https://example.com/post',
title: '예시 제목',
description: '예시 설명',
thumbnail: ''
})
assert.deepEqual(parseBookmarkMeta([
'url=https://example.com',
'title=제목',
'desc=설명',
'image=/thumb.webp'
].join('\n')), {
url: 'https://example.com',
title: '제목',
description: '설명',
thumbnail: '/thumb.webp'
})
})
test('회원가입 메타는 기본값을 유지하며 전달된 필드만 바꾼다', () => {
assert.deepEqual(parseSignupMeta('title=구독\nbutton=시작하기'), {
title: '구독',
description: '새 글이 올라오면 받아보실 수 있어요.',
button: '시작하기',
placeholder: 'you@example.com'
})
})
test('미디어 메타는 key=value와 순차 본문을 모두 파싱한다', () => {
assert.deepEqual(parseMediaMeta([
'/uploads/video.mp4',
'영상 제목',
'영상 설명'
].join('\n')), {
url: '/uploads/video.mp4',
title: '영상 제목',
description: '영상 설명',
poster: '',
caption: '',
fileName: '',
size: ''
})
assert.deepEqual(parseMediaMeta([
'src=/uploads/audio.mp3',
'title=오디오',
'thumbnail=/poster.webp',
'caption=캡션',
'filename=sound.mp3',
'size=3MB'
].join('\n')), {
url: '/uploads/audio.mp3',
title: '오디오',
description: '',
poster: '/poster.webp',
caption: '캡션',
fileName: 'sound.mp3',
size: '3MB'
})
})