// markdown.js — 의존성 없는 초경량 마크다운 렌더러 // // 보안 원칙 (매우 중요): // 1) 원본을 "먼저 escape" 한 뒤에 HTML 을 조립한다. 따라서 작성자가 넣은 raw HTML 은 // 절대 살아남지 못한다. // 2) 펜스 코드 블록은 인라인 처리 이전에 먼저 뽑아내고, 그 내용은 다시 변환하지 않는다. // 3) 링크/이미지 URL 은 화이트리스트(http:, https:, mailto:, '/' 로 시작하는 경로)만 통과시킨다. // 4) 내부 자리표시자로 쓰는 PUA 문자는 입력 단계에서 제거해 위조를 막는다. /** 펜스 코드 블록 자리표시자 경계 문자 (U+E000, 사용자 입력에서는 제거된다) */ const CODE_MARK = '\ue000'; /** 인라인 보호 구간 자리표시자 경계 문자 (U+E001) */ const SHIELD_MARK = '\ue001'; const CODE_PLACEHOLDER_RE = /\ue000(\d+)\ue000/g; const CODE_PLACEHOLDER_LINE_RE = /^\s*\ue000(\d+)\ue000\s*$/; const SHIELD_RE = /\ue001(\d+)\ue001/g; const HEADING_RE = /^ {0,3}(#{1,6})(?:[ \t]+(.*))?$/; const HR_RE = /^ {0,3}(?:-[ \t]*){3,}$|^ {0,3}(?:\*[ \t]*){3,}$|^ {0,3}(?:_[ \t]*){3,}$/; const QUOTE_RE = /^ {0,3}>[ \t]?/; const BULLET_RE = /^([ \t]*)([-*+])[ \t]+(.*)$/; const ORDERED_RE = /^([ \t]*)(\d{1,9})[.)][ \t]+(.*)$/; const ESCAPE_MAP = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; /** HTML escape (markdown.js 는 자체 완결이므로 ui.js 를 import 하지 않는다) */ function escapeHtml(str) { if (str === null || str === undefined) return ''; return String(str).replace(/[&<>"']/g, (ch) => ESCAPE_MAP[ch]); } /** 개행 통일, 탭 전개, 내부 자리표시자 문자 제거 */ function normalizeSource(src) { if (src === null || src === undefined) return ''; return String(src) .replace(/\x00/g, '') .replace(/[\ue000\ue001]/g, '') .replace(/\r\n?/g, '\n') .replace(/\t/g, ' '); } // --------------------------------------------------------------------------- // URL 화이트리스트 // --------------------------------------------------------------------------- /** 코드포인트를 안전한 문자로. 범위를 벗어나면 빈 문자열 */ function safeFromCodePoint(code) { if (!Number.isFinite(code) || code < 0 || code > 0x10ffff) return ''; try { return String.fromCodePoint(code); } catch { return ''; } } /** 검사 전용 엔티티 디코드 1회 (출력에는 절대 쓰지 않는다) */ function decodeEntitiesOnce(text) { return text .replace(/&#x([0-9a-fA-F]{1,6});?/g, (m, hex) => safeFromCodePoint(parseInt(hex, 16))) .replace(/&#(\d{1,7});?/g, (m, dec) => safeFromCodePoint(parseInt(dec, 10))) .replace(/&(amp|lt|gt|quot|apos|tab|newline|colon|sol|nbsp);?/gi, (m, name) => { const key = name.toLowerCase(); if (key === 'amp') return '&'; if (key === 'lt') return '<'; if (key === 'gt') return '>'; if (key === 'quot') return '"'; if (key === 'apos') return "'"; if (key === 'tab') return '\t'; if (key === 'newline') return '\n'; if (key === 'colon') return ':'; if (key === 'sol') return '/'; return ' '; }); } /** * 링크/이미지 URL 허용 여부. * 허용: http:, https:, mailto:, 그리고 '/' 로 시작하는 사이트 상대 경로. * 거부: javascript:, data:, vbscript:, 프로토콜 상대 '//', 제어문자·공백·엔티티 우회. * @param {string} escapedUrl 이미 escape 된 URL 문자열 * @returns {boolean} */ function isAllowedUrl(escapedUrl) { if (typeof escapedUrl !== 'string' || escapedUrl === '') return false; // 엔티티를 반복 디코드해 우회 시도를 드러낸다. let probe = escapedUrl; for (let i = 0; i < 3; i += 1) { const next = decodeEntitiesOnce(probe); if (next === probe) break; probe = next; } // 제어문자 · 공백(선행 공백 포함) · 삭제문자가 하나라도 있으면 거부 if (/[\x00- \x7f-\xa0\u2028\u2029\ufeff]/.test(probe)) return false; const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(probe); if (scheme) { const name = scheme[1].toLowerCase(); return name === 'http' || name === 'https' || name === 'mailto'; } // 스킴이 없으면 사이트 루트 상대 경로만 허용 ('//host' 형태는 프로토콜 상대이므로 거부) return probe.charAt(0) === '/' && probe.charAt(1) !== '/'; } /** http/https 절대 URL 인지 (새 탭으로 열지 결정) */ function isExternalUrl(escapedUrl) { return /^https?:/i.test(decodeEntitiesOnce(escapedUrl)); } // --------------------------------------------------------------------------- // 펜스 코드 블록: 인라인 처리 이전에 통째로 뽑아낸다 // --------------------------------------------------------------------------- /** 언어 이름 정리 (클래스 이름에 들어가므로 문자 제한) */ function normalizeLanguage(raw) { if (!raw) return ''; return String(raw) .trim() .toLowerCase() .replace(/[^a-z0-9_+#.-]/g, '') .slice(0, 32); } /** 코드 블록 HTML 조립 (내용은 escape 만 하고 그 밖의 변환은 하지 않는다) */ function buildCodeBlock(code, language) { const lang = normalizeLanguage(language); const openCode = lang ? '' : ''; return '
' + openCode + escapeHtml(code) + '
'; } /** * ``` / ~~~ 펜스 블록을 자리표시자로 치환한다. * @param {string} source 정규화된 원본 * @param {string[]} store 블록 HTML 저장소 * @returns {string} */ function extractFencedBlocks(source, store) { const lines = source.split('\n'); const out = []; let i = 0; while (i < lines.length) { const open = /^ {0,3}(`{3,}|~{3,})[ \t]*([^\s`]*)[ \t]*$/.exec(lines[i]); if (!open) { out.push(lines[i]); i += 1; continue; } const fence = open[1]; const fenceChar = fence.charAt(0); const language = open[2] || ''; const body = []; i += 1; while (i < lines.length) { const close = new RegExp('^ {0,3}\\' + fenceChar + '{' + fence.length + ',}[ \\t]*$'); if (close.test(lines[i])) { i += 1; break; } body.push(lines[i]); i += 1; } store.push(buildCodeBlock(body.join('\n'), language)); out.push(CODE_MARK + (store.length - 1) + CODE_MARK); } return out.join('\n'); } /** 남아 있는 코드 자리표시자를 실제 HTML 로 되돌린다 */ function expandCodePlaceholders(html, store) { return html.replace(CODE_PLACEHOLDER_RE, (m, idx) => { const entry = store[Number(idx)]; return entry === undefined ? '' : entry; }); } // --------------------------------------------------------------------------- // 인라인 처리 // --------------------------------------------------------------------------- /** 조각을 보호 구간으로 넣고 자리표시자를 돌려준다 */ function shield(store, html) { store.push(html); return SHIELD_MARK + (store.length - 1) + SHIELD_MARK; } /** 보호 구간 복원 (중첩 대비 최대 4회) */ function restoreShields(text, store) { let out = text; for (let pass = 0; pass < 4; pass += 1) { if (out.indexOf(SHIELD_MARK) === -1) break; out = out.replace(SHIELD_RE, (m, idx) => { const entry = store[Number(idx)]; return entry === undefined ? '' : entry; }); } return out; } /** 자리표시자 흔적 제거 (alt / title 처럼 평문만 허용되는 곳에서 사용) */ function stripMarks(text) { return text.replace(SHIELD_RE, '').replace(CODE_PLACEHOLDER_RE, '').replace(/[\ue000\ue001]/g, ''); } /** 인라인 코드(`code`)를 먼저 보호해 내부가 다시 변환되지 않도록 한다 */ function shieldInlineCode(text, store) { return text.replace(/(`+)([^`]|[\s\S]*?[^`])\1(?!`)/g, (match, ticks, body) => { let code = body; if (code.length > 1 && code.charAt(0) === ' ' && code.charAt(code.length - 1) === ' ') { code = code.slice(1, -1); } // code 는 이미 escape 된 문자열이므로 그대로 사용한다. return shield(store, '' + code + ''); }); } /** * alt 끝에 붙은 '|크기' 지시자를 떼어 낸다. * ![캡션|half](주소) → { caption: '캡션', size: 'half' } * 허용값: small · half · wide · full · 두세 자리 숫자(px). 그 밖은 캡션의 일부로 본다. */ function parseAltSize(altText) { const raw = String(altText === null || altText === undefined ? '' : altText); const bar = raw.lastIndexOf('|'); if (bar < 0) return { caption: raw.trim(), size: '' }; const hint = raw.slice(bar + 1).trim().toLowerCase(); if (!/^(small|half|wide|full|[1-9]\d{1,3})$/.test(hint)) { return { caption: raw.trim(), size: '' }; } return { caption: raw.slice(0, bar).trim(), size: hint }; } /** 이미지: 항상 figure 로 감싼다. 캡션이 있으면 figcaption 도 붙인다. */ function buildFigure(escapedUrl, altText, titleText) { const parsed = parseAltSize(altText); const caption = stripMarks(parsed.caption); const title = titleText ? ' title="' + stripMarks(titleText) + '"' : ''; let cls = 'md-figure'; let style = ''; if (parsed.size === 'small' || parsed.size === 'half' || parsed.size === 'wide') { cls += ' md-figure--' + parsed.size; } else if (/^\d+$/.test(parsed.size)) { // 숫자는 px 폭. 본문보다 넓어지지 않게 CSS 에서 한 번 더 자른다. cls += ' md-figure--fixed'; style = ' style="--md-img-w:' + parsed.size + 'px"'; } return ( '
' + '' +
        caption +
        '' + (caption ? '
' + caption + '
' : '') + '
' ); } const IMAGE_RE = /!\[([^\]]*)\]\([ \t]*([^\s)]*)(?:[ \t]+"([^)]*?)")?[ \t]*\)/g; const LINK_RE = /\[([^\]]*)\]\([ \t]*([^\s)]*)(?:[ \t]+"([^)]*?)")?[ \t]*\)/g; /** ![alt](url) → figure. URL 이 거부되면 alt 텍스트만 남긴다. */ function renderImages(text, store) { return text.replace(IMAGE_RE, (match, alt, url, title) => { if (!isAllowedUrl(url)) return stripMarks(parseAltSize(alt).caption); return shield(store, buildFigure(url, alt, title)); }); } /** [text](url) → a. URL 이 거부되면 링크를 버리고 텍스트만 남긴다. */ function renderLinks(text, store) { return text.replace(LINK_RE, (match, label, url, title) => { if (!isAllowedUrl(url)) return label; const titleAttr = title ? ' title="' + stripMarks(title) + '"' : ''; // 외부 링크만 새 탭으로 열고 rel 로 opener 를 끊는다. const targetAttr = isExternalUrl(url) ? ' target="_blank" rel="noopener noreferrer"' : ''; const open = ''; // 여는/닫는 태그만 보호하고 label 은 흐름에 남겨, // 링크 글자 안의 **굵게** 같은 강조가 그대로 처리되게 한다. return shield(store, open) + label + shield(store, ''); }); } /** 강조/취소선 */ function renderEmphasis(text) { let out = text; out = out.replace(/~~([^\s~](?:[\s\S]*?[^\s~])?)~~/g, '$1'); out = out.replace(/\*\*([^\s](?:[\s\S]*?[^\s])?)\*\*/g, '$1'); out = out.replace(/(^|[^\w_])__([^\s_](?:[\s\S]*?[^\s_])?)__(?=[^\w]|$)/g, '$1$2'); out = out.replace(/\*([^\s*](?:[^*]*[^\s*])?)\*/g, '$1'); out = out.replace(/(^|[^\w_])_([^\s_](?:[^_]*[^\s_])?)_(?=[^\w]|$)/g, '$1$2'); return out; } /** * 인라인 변환 파이프라인. 입력은 반드시 "이미 escape 된" 텍스트여야 한다. * @param {string} escapedText * @returns {string} */ function renderInline(escapedText) { const store = []; let out = shieldInlineCode(escapedText, store); out = renderImages(out, store); out = renderLinks(out, store); out = renderEmphasis(out); return restoreShields(out, store); } // --------------------------------------------------------------------------- // 블록 처리 // --------------------------------------------------------------------------- const FIGURE_ONLY_RE = /
]*>[\s\S]*?<\/figure>/g; function hasFigure(html) { return html.indexOf('
' + '' + '
' ); } /** 문단 HTML 조립 (figure 가 섞이면 p 대신 div 로 감싸 유효한 HTML 을 유지한다) */ function buildParagraph(inlineHtml) { if (!inlineHtml.trim()) return ''; if (isFigureOnly(inlineHtml)) return inlineHtml; if (hasFigure(inlineHtml)) return '
' + inlineHtml + '
'; return '

' + inlineHtml + '

'; } function leadingSpaces(line) { const m = /^[ ]*/.exec(line); return m ? m[0].length : 0; } function matchListItem(line) { const bullet = BULLET_RE.exec(line); if (bullet) { return { indent: bullet[1].length, ordered: false, start: 1, text: bullet[3] }; } const ordered = ORDERED_RE.exec(line); if (ordered) { return { indent: ordered[1].length, ordered: true, start: parseInt(ordered[2], 10), text: ordered[3] }; } return null; } function isListLine(line) { return matchListItem(line) !== null; } /** 새 블록이 시작되는 줄인지 (문단 수집을 멈출 지점) */ function isBlockStart(line) { if (CODE_PLACEHOLDER_LINE_RE.test(line)) return true; if (HR_RE.test(line)) return true; if (HEADING_RE.test(line)) return true; if (QUOTE_RE.test(line)) return true; if (isListLine(line)) return true; return false; } /** 여러 줄을 하나의 문단 텍스트로 합친다. 끝에 공백 2개 이상이면 하드 브레이크. */ function joinParagraphLines(lines) { let text = ''; for (let i = 0; i < lines.length; i += 1) { const isLast = i === lines.length - 1; const raw = lines[i]; const trimmedEnd = raw.replace(/[ \t]+$/, ''); if (isLast) { text += trimmedEnd; } else if (/[ \t]{2,}$/.test(raw) || /\\$/.test(trimmedEnd)) { text += trimmedEnd.replace(/\\$/, '') + '
\n'; } else { text += trimmedEnd + '\n'; } } return text; } /** 목록 노드 트리를 HTML 로 */ function renderListNodes(nodes, ordered, start) { const tag = ordered ? 'ol' : 'ul'; const cls = ordered ? 'md-ol' : 'md-ul'; const startAttr = ordered && typeof start === 'number' && start !== 1 ? ' start="' + start + '"' : ''; let html = '<' + tag + ' class="' + cls + '"' + startAttr + '>'; for (const node of nodes) { html += '
  • ' + renderInline(node.text); if (node.children && node.children.length > 0) { html += renderListNodes(node.children, node.childOrdered, node.childStart); } html += '
  • '; } html += ''; return html; } // --------------------------------------------------------------------------- // 블록 파서 본체 // --------------------------------------------------------------------------- function parseBlocks(lines, codeStore, depth) { const out = []; const level = typeof depth === 'number' ? depth : 0; let i = 0; while (i < lines.length) { const line = lines[i]; if (!line.trim()) { i += 1; continue; } // 1) 펜스 코드 블록 자리표시자 const codeLine = CODE_PLACEHOLDER_LINE_RE.exec(line); if (codeLine) { const entry = codeStore[Number(codeLine[1])]; if (entry !== undefined) out.push(entry); i += 1; continue; } // 2) 수평선 if (HR_RE.test(line)) { out.push('
    '); i += 1; continue; } // 3) ATX 제목 const heading = HEADING_RE.exec(line); if (heading) { const lvl = heading[1].length; const body = (heading[2] || '').replace(/[ \t]+#+[ \t]*$/, '').trim(); out.push('' + renderInline(body) + ''); i += 1; continue; } // 4) 인용문 (escape 후이므로 '>' 는 '>') if (QUOTE_RE.test(line)) { const inner = []; while (i < lines.length) { const cur = lines[i]; if (QUOTE_RE.test(cur)) { inner.push(cur.replace(QUOTE_RE, '')); i += 1; continue; } // 게으른 이어쓰기: 빈 줄이 아니고 새 블록 시작도 아니면 인용 안으로 if (cur.trim() && !isBlockStart(cur) && inner.length > 0) { inner.push(cur); i += 1; continue; } break; } const innerHtml = level < 4 ? parseBlocks(inner, codeStore, level + 1) : buildParagraph(renderInline(inner.join('\n'))); out.push('
    ' + innerHtml + '
    '); continue; } // 5) 목록 (한 단계 중첩까지) if (isListLine(line)) { const items = []; while (i < lines.length) { const cur = lines[i]; if (!cur.trim()) { // 빈 줄 뒤에도 목록이 이어지면 계속 let j = i + 1; while (j < lines.length && !lines[j].trim()) j += 1; if (j < lines.length && (isListLine(lines[j]) || leadingSpaces(lines[j]) >= 2)) { i = j; continue; } break; } const item = matchListItem(cur); if (item) { items.push(item); i += 1; continue; } if (items.length > 0 && leadingSpaces(cur) >= 2) { const last = items[items.length - 1]; last.text += '\n' + cur.trim(); i += 1; continue; } break; } const baseIndent = items[0].indent; const nodes = []; for (const item of items) { if (item.indent >= baseIndent + 2 && nodes.length > 0) { const parent = nodes[nodes.length - 1]; if (!parent.children) { parent.children = []; parent.childOrdered = item.ordered; parent.childStart = item.start; } parent.children.push({ text: item.text, children: null }); } else { nodes.push({ text: item.text, children: null, childOrdered: false, childStart: 1 }); } } out.push(renderListNodes(nodes, items[0].ordered, items[0].start)); continue; } // 6) 문단 const buf = [line]; i += 1; while (i < lines.length) { const cur = lines[i]; if (!cur.trim()) break; if (isBlockStart(cur)) break; buf.push(cur); i += 1; } // URL 한 줄짜리 문단이면 유튜브 재생기로 바꾼다. const embed = buf.length === 1 ? parseYouTube(buf[0]) : null; if (embed) { out.push(buildYouTubeEmbed(embed)); } else { out.push(buildParagraph(renderInline(joinParagraphLines(buf)))); } } return out.join('\n'); } // --------------------------------------------------------------------------- // 공개 API // --------------------------------------------------------------------------- /** * 마크다운을 HTML 문자열로 변환한다. 반환값은 구조적으로 안전한(escape 완료) HTML 이다. * @param {string} src * @returns {string} */ export function renderMarkdown(src) { const normalized = normalizeSource(src); if (!normalized.trim()) return ''; // 1) 펜스 코드 먼저 분리 (내용은 escape 만 하고 더 이상 건드리지 않는다) const codeStore = []; const withPlaceholders = extractFencedBlocks(normalized, codeStore); // 2) 남은 본문을 통째로 escape → 이후 단계에서 raw HTML 이 생길 수 없다 const escaped = escapeHtml(withPlaceholders); // 3) 블록 파싱 → 인라인 파싱 const html = parseBlocks(escaped.split('\n'), codeStore, 0); // 4) 목록/문단 안쪽에 남은 코드 자리표시자 마무리 return expandCodePlaceholders(html, codeStore); } export default renderMarkdown;