194 lines
6.8 KiB
Python
194 lines
6.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Small, dependency-free parsers for the wiki's structured Markdown contracts."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import re
|
|
from typing import Iterable
|
|
|
|
|
|
FM_RE = re.compile(r"^([A-Za-z_][\w-]*):\s*(.*)$")
|
|
HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
|
|
TABLE_SEP_RE = re.compile(r"^:?-{3,}:?$")
|
|
SECTION_ID_RE = re.compile(r"^\s*<!--\s*section-id:\s*([a-z0-9][a-z0-9-]*)\s*-->\s*$")
|
|
|
|
|
|
# 셀 전체가 하나의 코드스팬/강조일 때만 벗긴다. 예전에는 strip("`* ") 로 양끝 문자를
|
|
# 무조건 깎았는데, 그러면 코드스팬으로 *시작만* 하는 셀이 여는 백틱을 잃는다 —
|
|
# "`domain <- application` 의존 방향과 …" 가 "domain <- application` 의존 방향과 …" 가 돼
|
|
# 투영된 표 11곳(hub 자신의 생성 블록 포함)에서 코드스팬이 깨져 있었다.
|
|
SINGLE_CODE_SPAN_RE = re.compile(r"^`([^`]*)`$")
|
|
SINGLE_EMPHASIS_RE = re.compile(r"^(\*{1,2})([^*]*)\1$")
|
|
|
|
|
|
def clean(value: str) -> str:
|
|
value = value.strip()
|
|
while True:
|
|
match = SINGLE_CODE_SPAN_RE.match(value) or SINGLE_EMPHASIS_RE.match(value)
|
|
if match is None:
|
|
break
|
|
value = match.group(match.lastindex).strip()
|
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
|
|
value = value[1:-1]
|
|
return value.strip()
|
|
|
|
|
|
def parse_frontmatter(text: str) -> dict[str, object]:
|
|
lines = text.splitlines()
|
|
if not lines or lines[0].strip() != "---":
|
|
return {}
|
|
values: dict[str, object] = {}
|
|
current: str | None = None
|
|
for line in lines[1:]:
|
|
if line.strip() == "---":
|
|
break
|
|
match = FM_RE.match(line)
|
|
if match:
|
|
current = match.group(1)
|
|
raw = match.group(2).strip()
|
|
if raw.startswith("[") and raw.endswith("]"):
|
|
values[current] = [clean(item) for item in raw[1:-1].split(",") if clean(item)]
|
|
else:
|
|
values[current] = clean(raw)
|
|
continue
|
|
item = re.match(r"^\s+-\s+(.+?)\s*$", line)
|
|
if item and current:
|
|
if not isinstance(values.get(current), list):
|
|
values[current] = []
|
|
assert isinstance(values[current], list)
|
|
values[current].append(clean(item.group(1)))
|
|
return values
|
|
|
|
|
|
def as_list(value: object) -> list[str]:
|
|
if isinstance(value, list):
|
|
return [str(item).strip() for item in value if str(item).strip()]
|
|
if isinstance(value, str) and value.strip():
|
|
return [value.strip()]
|
|
return []
|
|
|
|
|
|
def split_row(line: str) -> list[str]:
|
|
token = "\x00PIPE\x00"
|
|
return [
|
|
cell.strip().replace(token, "|")
|
|
for cell in line.strip().strip("|").replace("\\|", token).split("|")
|
|
]
|
|
|
|
|
|
def header_key(value: str) -> str:
|
|
return re.sub(r"[^0-9a-zA-Z가-힣]+", "", value).lower()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MarkdownTable:
|
|
headings: tuple[str, ...]
|
|
section_ids: tuple[str, ...]
|
|
headers: tuple[str, ...]
|
|
header_line: int
|
|
rows: tuple[tuple[int, dict[str, str]], ...]
|
|
|
|
|
|
def parse_tables(text: str) -> list[MarkdownTable]:
|
|
lines = text.splitlines()
|
|
headings: dict[int, str] = {}
|
|
section_ids: dict[int, str] = {}
|
|
pending_section_id = ""
|
|
tables: list[MarkdownTable] = []
|
|
index = 0
|
|
while index < len(lines):
|
|
section_id = SECTION_ID_RE.match(lines[index])
|
|
if section_id:
|
|
pending_section_id = section_id.group(1)
|
|
index += 1
|
|
continue
|
|
heading = HEADING_RE.match(lines[index])
|
|
if heading:
|
|
level = len(heading.group(1))
|
|
headings = {key: value for key, value in headings.items() if key < level}
|
|
section_ids = {key: value for key, value in section_ids.items() if key < level}
|
|
headings[level] = heading.group(2).strip()
|
|
if pending_section_id:
|
|
section_ids[level] = pending_section_id
|
|
pending_section_id = ""
|
|
index += 1
|
|
continue
|
|
if (
|
|
lines[index].lstrip().startswith("|")
|
|
and index + 1 < len(lines)
|
|
and lines[index + 1].lstrip().startswith("|")
|
|
):
|
|
headers = split_row(lines[index])
|
|
separators = split_row(lines[index + 1])
|
|
if len(headers) == len(separators) and all(TABLE_SEP_RE.fullmatch(item) for item in separators):
|
|
rows: list[tuple[int, dict[str, str]]] = []
|
|
cursor = index + 2
|
|
keys = [header_key(header) for header in headers]
|
|
while cursor < len(lines) and lines[cursor].lstrip().startswith("|"):
|
|
cells = split_row(lines[cursor])
|
|
cells += [""] * (len(headers) - len(cells))
|
|
rows.append((cursor + 1, dict(zip(keys, cells))))
|
|
cursor += 1
|
|
tables.append(
|
|
MarkdownTable(
|
|
headings=tuple(headings.values()),
|
|
section_ids=tuple(section_ids.values()),
|
|
headers=tuple(headers),
|
|
header_line=index + 1,
|
|
rows=tuple(rows),
|
|
)
|
|
)
|
|
index = cursor
|
|
continue
|
|
index += 1
|
|
return tables
|
|
|
|
|
|
def table_for(tables: Iterable[MarkdownTable], *headings: str) -> MarkdownTable | None:
|
|
"""Return the first table under any accepted localized heading.
|
|
|
|
Structured column names remain stable machine schema. Section headings are
|
|
presentation text, so readers and migration tools accept both the current
|
|
Korean title and the legacy English title during rollout.
|
|
"""
|
|
needles = tuple(heading.casefold() for heading in headings)
|
|
return next(
|
|
(
|
|
table
|
|
for table in tables
|
|
if any(
|
|
needle in item.casefold()
|
|
for needle in needles
|
|
for item in (*table.headings, *(f"section-id:{value}" for value in table.section_ids))
|
|
)
|
|
),
|
|
None,
|
|
)
|
|
|
|
|
|
def cell(row: dict[str, str], name: str) -> str:
|
|
return row.get(header_key(name), "")
|
|
|
|
|
|
def replace_table_cell(
|
|
text: str,
|
|
table: MarkdownTable,
|
|
row_line: int,
|
|
header_name: str,
|
|
value: str,
|
|
) -> str:
|
|
keys = [header_key(header) for header in table.headers]
|
|
target_key = header_key(header_name)
|
|
if target_key not in keys:
|
|
raise ValueError(f"table has no {header_name!r} column")
|
|
lines = text.splitlines(keepends=True)
|
|
original = lines[row_line - 1]
|
|
newline = "\n" if original.endswith("\n") else ""
|
|
cells = split_row(original.rstrip("\n"))
|
|
cells += [""] * (len(keys) - len(cells))
|
|
cells[keys.index(target_key)] = value
|
|
rendered = [item.replace("|", "\\|") for item in cells[: len(keys)]]
|
|
lines[row_line - 1] = "| " + " | ".join(rendered) + " |" + newline
|
|
return "".join(lines)
|