마크다운 fenced 메타 파싱 분리
This commit is contained in:
@@ -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 - 마크다운 문자열
|
||||
|
||||
@@ -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 이스케이프 단위 회귀 테스트 |
|
||||
|
||||
@@ -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
172
lib/markdown-fenced-meta.js
Normal 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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.48",
|
||||
"version": "1.11.49",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
100
tests/markdown-fenced-meta.test.js
Normal file
100
tests/markdown-fenced-meta.test.js
Normal 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'
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user