#!/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*$") # 셀 전체가 하나의 코드스팬/강조일 때만 벗긴다. 예전에는 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)