feat: initial CTStruct framework

- CTNode: 트리 기본 단위 (전위/후위/BFS 순회, 탐색, 분리)
- CTTree: 트리 컨테이너 (빌더 API, TOC, filter/map/merge)
- NodeType: 구조/블록/인라인/메타 노드 타입 Enum
- MarkdownParser: Markdown → CTTree 파서
- MarkdownRenderer: CTTree → Markdown 렌더러
- JSONRenderer: CTTree → JSON 직렬화
- 27개 단위/통합 테스트 전체 통과
This commit is contained in:
2026-05-30 20:40:28 +09:00
commit 6e3cd19b55
38 changed files with 1695 additions and 0 deletions

View File

@@ -0,0 +1,172 @@
"""
ctstruct/parsers/markdown.py
Markdown → CTTree 파서
"""
from __future__ import annotations
import re
from ..node import CTNode
from ..tree import CTTree
from ..types import NodeType, ListStyle
from .base import BaseParser
class MarkdownParser(BaseParser):
"""
Markdown 텍스트를 CTTree 로 파싱.
지원 요소:
- ATX 헤딩 (# ~ ######)
- 문단
- 순서없는 목록 (-, *, +)
- 순서있는 목록 (1. 2. …)
- 코드 블록 (``` … ```)
- 인용구 (>)
- 구분선 (--- / ***)
- frontmatter (--- … ---)
"""
# ── 정규식 패턴 ──────────────────────────────────────────
_RE_HEADING = re.compile(r'^(#{1,6})\s+(.+)$')
_RE_UL_ITEM = re.compile(r'^(\s*)[*\-+]\s+(.+)$')
_RE_OL_ITEM = re.compile(r'^(\s*)\d+\.\s+(.+)$')
_RE_CODE_FENCE = re.compile(r'^```(\w*)$')
_RE_BLOCKQUOTE = re.compile(r'^>\s?(.*)$')
_RE_DIVIDER = re.compile(r'^(\-{3,}|\*{3,}|_{3,})$')
_RE_FRONTMATTER = re.compile(r'^---\s*$')
def parse(self, text: str) -> CTTree:
tree = CTTree()
lines = text.splitlines()
i = 0
# 1) frontmatter
metadata, i = self._parse_frontmatter(lines, i)
if metadata:
tree.root.set_attr("metadata", metadata)
# 2) 본문 파싱
current_section: CTNode | None = None
while i < len(lines):
line = lines[i]
# 빈 줄 → 스킵
if not line.strip():
i += 1
continue
# 코드 블록
m = self._RE_CODE_FENCE.match(line)
if m:
lang = m.group(1)
code_lines, i = self._collect_code_block(lines, i + 1)
node = CTNode(NodeType.CODE_BLOCK, "\n".join(code_lines), {"language": lang})
self._attach(tree, current_section, node)
continue
# 헤딩
m = self._RE_HEADING.match(line)
if m:
level = len(m.group(1))
text_content = m.group(2).strip()
heading = CTNode(NodeType.HEADING, text_content, {"level": level})
if level == 1:
# 새 섹션 시작
current_section = CTNode(NodeType.SECTION)
current_section.append(heading)
tree.root.append(current_section)
else:
self._attach(tree, current_section, heading)
i += 1
continue
# 구분선
if self._RE_DIVIDER.match(line):
node = CTNode(NodeType.DIVIDER)
self._attach(tree, current_section, node)
i += 1
continue
# 인용구
m = self._RE_BLOCKQUOTE.match(line)
if m:
quote_lines = [m.group(1)]
i += 1
while i < len(lines) and self._RE_BLOCKQUOTE.match(lines[i]):
quote_lines.append(self._RE_BLOCKQUOTE.match(lines[i]).group(1)) # type: ignore
i += 1
node = CTNode(NodeType.BLOCKQUOTE, "\n".join(quote_lines))
self._attach(tree, current_section, node)
continue
# 목록
if self._RE_UL_ITEM.match(line) or self._RE_OL_ITEM.match(line):
lst_node, i = self._parse_list(lines, i)
self._attach(tree, current_section, lst_node)
continue
# 문단
para_lines = [line]
i += 1
while i < len(lines) and lines[i].strip() and not self._is_block_start(lines[i]):
para_lines.append(lines[i])
i += 1
node = CTNode(NodeType.PARAGRAPH, " ".join(para_lines))
self._attach(tree, current_section, node)
return tree
# ── 헬퍼 ─────────────────────────────────────────────────
@staticmethod
def _attach(tree: CTTree, section: CTNode | None, node: CTNode) -> None:
if section is not None:
section.append(node)
else:
tree.root.append(node)
def _parse_frontmatter(self, lines: list[str], i: int) -> tuple[dict, int]:
if i >= len(lines) or not self._RE_FRONTMATTER.match(lines[i]):
return {}, i
i += 1
meta: dict = {}
while i < len(lines) and not self._RE_FRONTMATTER.match(lines[i]):
if ":" in lines[i]:
k, _, v = lines[i].partition(":")
meta[k.strip()] = v.strip()
i += 1
return meta, i + 1
def _collect_code_block(self, lines: list[str], i: int) -> tuple[list[str], int]:
code: list[str] = []
while i < len(lines):
if lines[i].strip() == "```":
return code, i + 1
code.append(lines[i])
i += 1
return code, i
def _parse_list(self, lines: list[str], i: int) -> tuple[CTNode, int]:
is_ordered = bool(self._RE_OL_ITEM.match(lines[i]))
style = ListStyle.ORDERED if is_ordered else ListStyle.UNORDERED
lst = CTNode(NodeType.LIST, attributes={"style": style.value})
pattern = self._RE_OL_ITEM if is_ordered else self._RE_UL_ITEM
while i < len(lines):
m = pattern.match(lines[i])
if not m:
break
lst.append(CTNode(NodeType.LIST_ITEM, m.group(2)))
i += 1
return lst, i
def _is_block_start(self, line: str) -> bool:
return bool(
self._RE_HEADING.match(line)
or self._RE_CODE_FENCE.match(line)
or self._RE_BLOCKQUOTE.match(line)
or self._RE_DIVIDER.match(line)
or self._RE_UL_ITEM.match(line)
or self._RE_OL_ITEM.match(line)
)