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:
172
ctstruct/parsers/markdown.py
Normal file
172
ctstruct/parsers/markdown.py
Normal 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)
|
||||
)
|
||||
Reference in New Issue
Block a user