Files
Genesis_GameServer/GameServer/wwwroot/js/markdown.js
2026-09-03 19:18:46 +09:00

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 = {
'&': '&',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
};
/** 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 끝에 붙은 '|크기' 지시자를 떼어 낸다.
* ![캡션|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 (
'<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]+&quot;([^)]*?)&quot;)?[ \t]*\)/g;
const LINK_RE = /\[([^\]]*)\]\([ \t]*([^\s)]*)(?:[ \t]+&quot;([^)]*?)&quot;)?[ \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 = '<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 를 거친 줄 (& 가 &amp; 로 바뀌어 있다)
* @returns {{id:string, start:number}|null}
*/
function parseYouTube(escapedLine) {
const text = String(escapedLine || '').trim().replace(/&amp;/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 ? '&amp;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 후이므로 '>' 는 '&gt;')
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;