"""Phase T-1: MDX 4-Layer 파서.
Stage 0에서 호출. 원본 MDX를 정규화하여 이후 모든 Stage에 깨끗한 입력 제공.
Layer 1: python-frontmatter — YAML frontmatter 분리, title 추출
Layer 2: regex — 코드블록 보호 + MDX 전용 패턴 (details, :::, JSX, import)
Layer 3: markdown-it-py — AST 파싱 → 이미지/표/헤딩 구조 추출
Layer 4: regex — 텍스트 정리, 빈 줄 정리, clean_text
조사 결과 (T-1):
- python-frontmatter: parse() → (dict, str). frontmatter 없으면 안전하게 {}
- markdown-it-py: js-default 프리셋에 table 기본 포함. 한국어 정상
- 코드블록 보호: backtick 10→3 순서 매칭. 중첩/inline 검증됨
"""
from __future__ import annotations
import re
import logging
from typing import Any
import frontmatter
from markdown_it import MarkdownIt
logger = logging.getLogger(__name__)
# ══════════════════════════════════════
# 코드블록 보호 (Layer 2 선행)
# ══════════════════════════════════════
class _CodeBlockProtector:
"""코드블록을 placeholder로 보호하고 복원.
backtick 개수가 많은 순서(10→3)로 매칭하여 중첩 코드블록 안전 처리.
"""
def __init__(self):
self._store: dict[str, str] = {}
self._counter = 0
def _make_key(self) -> str:
self._counter += 1
return f"__CODEBLOCK_{self._counter}__"
def protect(self, text: str) -> str:
# fenced code blocks (큰 backtick부터)
for n in range(10, 2, -1):
pattern = rf"^(`{{{n}}})([^\n]*)\n(.*?)\n\1\s*$"
def _replacer(m, _n=n):
key = self._make_key()
self._store[key] = m.group(0)
return key
text = re.sub(pattern, _replacer, text, flags=re.MULTILINE | re.DOTALL)
# inline code
def _inline_replacer(m):
key = self._make_key()
self._store[key] = m.group(0)
return key
text = re.sub(r"`[^`\n]+`", _inline_replacer, text)
return text
def restore(self, text: str) -> str:
for key, original in self._store.items():
text = text.replace(key, original)
return text
# ══════════════════════════════════════
# Layer 2: MDX 전용 패턴 처리
# ══════════════════════════════════════
def _convert_md_list_to_html(text: str) -> str:
"""마크다운 리스트(* item, - item)를 HTML
- 로 변환.
들여쓰기 수준(2-4칸)을 감지하여 중첩
을 생성한다.
"""
lines = text.split("\n")
result = []
list_stack: list[int] = [] # 현재 열린 리스트의 들여쓰기 레벨들
for line in lines:
m = re.match(r"^(\s*)[*\-]\s+(.+)$", line)
if m:
indent = len(m.group(1))
content = m.group(2)
if not list_stack:
result.append("")
list_stack.append(indent)
elif indent > list_stack[-1]:
result.append("")
list_stack.append(indent)
else:
while len(list_stack) > 1 and indent < list_stack[-1]:
result.append("
")
list_stack.pop()
result.append(f"- {content}")
else:
while list_stack:
result.append("
")
list_stack.pop()
result.append(line)
while list_stack:
result.append("")
list_stack.pop()
return "\n".join(result)
def _convert_md_table_to_html(text: str) -> str:
"""마크다운 테이블(| col | col |)을 HTML 로 변환.
어떤 마크다운 테이블이든 동작. 하드코딩 없음.
"""
lines = text.split("\n")
result = []
table_lines = []
in_table = False
for line in lines:
stripped = line.strip()
if stripped.startswith("|") and stripped.endswith("|"):
table_lines.append(stripped)
in_table = True
else:
if in_table and table_lines:
result.append(_render_md_table(table_lines))
table_lines = []
in_table = False
result.append(line)
if table_lines:
result.append(_render_md_table(table_lines))
return "\n".join(result)
def _render_md_table(table_lines: list[str]) -> str:
"""마크다운 테이블 라인들을 HTML 테이블로."""
if len(table_lines) < 2:
return "\n".join(table_lines)
def _parse_row(line):
cells = [c.strip() for c in line.split("|")]
# 앞뒤 빈 셀 제거 (| col1 | col2 | → ['', 'col1', 'col2', ''])
return [c for c in cells if c or c == ""].__getitem__(slice(1, -1)) if cells[0] == "" else cells
headers = _parse_row(table_lines[0])
# 구분선(|---|---|) 스킵
data_start = 1
if len(table_lines) > 1 and all(c.strip().replace("-", "").replace(":", "") == "" for c in table_lines[1].split("|") if c.strip()):
data_start = 2
rows = [_parse_row(line) for line in table_lines[data_start:]]
# HTML 생성 — 셀 내
→
유지 (줄바꿈 역할)
header_html = "".join(f"{h} | " for h in headers)
rows_html = ""
for row in rows:
cells = ""
for c in row:
c = re.sub(r"
", "
", c)
cells += f"{c} | "
rows_html += f"{cells}
\n"
return f""
def _process_mdx_patterns(text: str) -> tuple[str, list[dict]]:
"""MDX 전용 패턴 처리. popups를 추출하고 텍스트에서 마커로 교체.
Returns:
(처리된 텍스트, popups 리스트)
"""
popups = []
# 제목
내용 → 팝업 추출
def _extract_popup(m):
title = m.group(1).strip()
content = m.group(2).strip()
# 팝업 content 정화: JSX style 제거 + 마크다운 → HTML
content = re.sub(r"", "", content)
content = content.replace("
", "")
# 마크다운 테이블 → HTML 테이블 (br 치환보다 먼저 — 셀 내
로 행이 쪼개지는 것 방지)
content = _convert_md_table_to_html(content)
# 테이블 밖
→ \n (테이블 안은 이미
로 변환 완료)
content = re.sub(r"
", "\n", content)
content = re.sub(r"\*\*(.+?)\*\*", r"\1", content)
# 마크다운 리스트(* item) → HTML