diff --git a/components/content/ContentMarkdownRenderer.vue b/components/content/ContentMarkdownRenderer.vue index 4734a0f..13aa7a6 100644 --- a/components/content/ContentMarkdownRenderer.vue +++ b/components/content/ContentMarkdownRenderer.vue @@ -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} lines - 전체 마크다운 행 - * @param {number} startIndex - 본문 시작 인덱스 - * @returns {{contentLines: Array, 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 - 마크다운 문자열 diff --git a/docs/map.md b/docs/map.md index e269748..ea3e8e6 100644 --- a/docs/map.md +++ b/docs/map.md @@ -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 이스케이프 단위 회귀 테스트 | diff --git a/docs/update.md b/docs/update.md index 78be467..ccbe264 100644 --- a/docs/update.md +++ b/docs/update.md @@ -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`로 분리. diff --git a/lib/markdown-fenced-meta.js b/lib/markdown-fenced-meta.js new file mode 100644 index 0000000..c278498 --- /dev/null +++ b/lib/markdown-fenced-meta.js @@ -0,0 +1,172 @@ +/** + * 닫힘 표식까지의 행 목록을 반환한다. + * @param {Array} lines - 전체 마크다운 행 + * @param {number} startIndex - 본문 시작 인덱스 + * @returns {{contentLines: Array, 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 +} diff --git a/package-lock.json b/package-lock.json index 3595dc4..31919b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 875387d..c7b885b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.11.48", + "version": "1.11.49", "private": true, "type": "module", "imports": { diff --git a/tests/markdown-fenced-meta.test.js b/tests/markdown-fenced-meta.test.js new file mode 100644 index 0000000..6f68880 --- /dev/null +++ b/tests/markdown-fenced-meta.test.js @@ -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' + }) +})