633 lines
23 KiB
JavaScript
633 lines
23 KiB
JavaScript
// 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 ? '<code class="lang-' + lang + '">' : '<code>';
|
|
return '<pre class="md-code">' + openCode + escapeHtml(code) + '</code></pre>';
|
|
}
|
|
|
|
/**
|
|
* ``` / ~~~ 펜스 블록을 자리표시자로 치환한다.
|
|
* @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 class="md-inline-code">' + code + '</code>');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* alt 끝에 붙은 '|크기' 지시자를 떼어 낸다.
|
|
*  → { 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 (
|
|
'<figure class="' + cls + '"' + style + '>' +
|
|
'<img class="md-img" loading="lazy" decoding="async" src="' +
|
|
escapedUrl +
|
|
'" alt="' +
|
|
caption +
|
|
'"' +
|
|
title +
|
|
'>' +
|
|
(caption ? '<figcaption class="md-cap">' + caption + '</figcaption>' : '') +
|
|
'</figure>'
|
|
);
|
|
}
|
|
|
|
const IMAGE_RE = /!\[([^\]]*)\]\([ \t]*([^\s)]*)(?:[ \t]+"([^)]*?)")?[ \t]*\)/g;
|
|
const LINK_RE = /\[([^\]]*)\]\([ \t]*([^\s)]*)(?:[ \t]+"([^)]*?)")?[ \t]*\)/g;
|
|
|
|
/**  → 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 = '<a class="md-link" href="' + url + '"' + titleAttr + targetAttr + '>';
|
|
|
|
// 여는/닫는 태그만 보호하고 label 은 흐름에 남겨,
|
|
// 링크 글자 안의 **굵게** 같은 강조가 그대로 처리되게 한다.
|
|
return shield(store, open) + label + shield(store, '</a>');
|
|
});
|
|
}
|
|
|
|
/** 강조/취소선 */
|
|
function renderEmphasis(text) {
|
|
let out = text;
|
|
out = out.replace(/~~([^\s~](?:[\s\S]*?[^\s~])?)~~/g, '<del>$1</del>');
|
|
out = out.replace(/\*\*([^\s](?:[\s\S]*?[^\s])?)\*\*/g, '<strong>$1</strong>');
|
|
out = out.replace(/(^|[^\w_])__([^\s_](?:[\s\S]*?[^\s_])?)__(?=[^\w]|$)/g, '$1<strong>$2</strong>');
|
|
out = out.replace(/\*([^\s*](?:[^*]*[^\s*])?)\*/g, '<em>$1</em>');
|
|
out = out.replace(/(^|[^\w_])_([^\s_](?:[^_]*[^\s_])?)_(?=[^\w]|$)/g, '$1<em>$2</em>');
|
|
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 = /<figure class="md-figure[^"]*"[^>]*>[\s\S]*?<\/figure>/g;
|
|
|
|
function hasFigure(html) {
|
|
return html.indexOf('<figure class="md-figure') !== -1;
|
|
}
|
|
|
|
function isFigureOnly(html) {
|
|
if (!hasFigure(html)) return false;
|
|
return html.replace(FIGURE_ONLY_RE, '').trim() === '';
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 유튜브 임베드
|
|
// URL 한 줄만 있는 문단은 재생기로 바꾼다. 문장 안에 섞인 주소는 그냥 링크로 둔다.
|
|
// 영상 ID 는 [A-Za-z0-9_-]{11} 로 검증한 값만 쓰므로 주소를 조작할 수 없다.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const YT_SHORT_RE = /^https?:\/\/(?:www\.|m\.)?youtu\.be\/([A-Za-z0-9_-]{11})(?:[?#].*)?$/i;
|
|
const YT_PATH_RE = /^https?:\/\/(?:www\.|m\.)?youtube\.com\/(?:shorts|embed|live|v)\/([A-Za-z0-9_-]{11})(?:[?#].*)?$/i;
|
|
const YT_WATCH_RE = /^https?:\/\/(?:www\.|m\.)?youtube\.com\/watch\?(.+)$/i;
|
|
|
|
/**
|
|
* 한 줄이 유튜브 주소인지 본다.
|
|
* @param {string} escapedLine escape 를 거친 줄 (& 가 & 로 바뀌어 있다)
|
|
* @returns {{id:string, start:number}|null}
|
|
*/
|
|
function parseYouTube(escapedLine) {
|
|
const text = String(escapedLine || '').trim().replace(/&/g, '&');
|
|
if (!/^https?:\/\//i.test(text)) return null;
|
|
|
|
let id = '';
|
|
let m = YT_SHORT_RE.exec(text) || YT_PATH_RE.exec(text);
|
|
if (m) {
|
|
id = m[1];
|
|
} else {
|
|
const w = YT_WATCH_RE.exec(text);
|
|
if (!w) return null;
|
|
const v = /(?:^|&)v=([A-Za-z0-9_-]{11})(?:&|$)/.exec(w[1]);
|
|
if (!v) return null;
|
|
id = v[1];
|
|
}
|
|
|
|
// 시작 시각 (t=90 / start=90 / t=90s)
|
|
const t = /[?&](?:t|start)=(\d{1,5})s?(?:&|$)/.exec(text);
|
|
return { id, start: t ? Number(t[1]) : 0 };
|
|
}
|
|
|
|
/** 검증된 ID 로만 조립한다. nocookie 도메인을 써서 추적을 줄인다. */
|
|
function buildYouTubeEmbed(info) {
|
|
const src = 'https://www.youtube-nocookie.com/embed/' + info.id +
|
|
'?rel=0' + (info.start > 0 ? '&start=' + info.start : '');
|
|
return (
|
|
'<figure class="md-embed">' +
|
|
'<iframe class="md-embed__frame" src="' + src + '"' +
|
|
' title="YouTube 영상" loading="lazy" allowfullscreen' +
|
|
' referrerpolicy="strict-origin-when-cross-origin"' +
|
|
' allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share">' +
|
|
'</iframe>' +
|
|
'</figure>'
|
|
);
|
|
}
|
|
|
|
/** 문단 HTML 조립 (figure 가 섞이면 p 대신 div 로 감싸 유효한 HTML 을 유지한다) */
|
|
function buildParagraph(inlineHtml) {
|
|
if (!inlineHtml.trim()) return '';
|
|
if (isFigureOnly(inlineHtml)) return inlineHtml;
|
|
if (hasFigure(inlineHtml)) return '<div class="md-p">' + inlineHtml + '</div>';
|
|
return '<p class="md-p">' + inlineHtml + '</p>';
|
|
}
|
|
|
|
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(/\\$/, '') + '<br>\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 += '<li class="md-li">' + renderInline(node.text);
|
|
if (node.children && node.children.length > 0) {
|
|
html += renderListNodes(node.children, node.childOrdered, node.childStart);
|
|
}
|
|
html += '</li>';
|
|
}
|
|
html += '</' + tag + '>';
|
|
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('<hr class="md-hr">');
|
|
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('<h' + lvl + ' class="md-h' + lvl + '">' + renderInline(body) + '</h' + lvl + '>');
|
|
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('<blockquote class="md-quote">' + innerHtml + '</blockquote>');
|
|
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;
|