from __future__ import annotations import re from typing import Any _HEADER_RE = re.compile(r"^(#{1,4})\s+(.+)$", re.MULTILINE) _BOLD_RE = re.compile(r"\*\*(.+?)\*\*") _ITALIC_RE = re.compile(r"\*(.+?)\*") _STRIKE_RE = re.compile(r"~~(.+?)~~") _INLINE_CODE_RE = re.compile(r"`([^`]+)`") _LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)") _CODE_BLOCK_RE = re.compile(r"```(\w*)\n?(.*?)```", re.DOTALL) _BLOCKQUOTE_RE = re.compile(r"^>\s?(.+)$", re.MULTILINE) _UNORDERED_LIST_RE = re.compile(r"^[-*+]\s+(.+)$", re.MULTILINE) _ORDERED_LIST_RE = re.compile(r"^\d+\.\s+(.+)$", re.MULTILINE) _SHIP_RE = re.compile(r"(? list[dict[str, Any]]: if not content: return [{"text": ""}] if ( "```" in content or "**" in content or "#" in content or ">" in content or "[" in content or "~" in content or _RULE_RE.search(content) ): return _parse_markdown_story(content) lines = content.split("\n") if len(lines) == 1: return [{"text": content}] story: list[dict[str, Any]] = [] for line in lines: if not line.strip(): story.append({"break": None}) else: story.append({"text": line}) return story def _parse_markdown_story(content: str) -> list[dict[str, Any]]: story: list[dict[str, Any]] = [] pos = 0 while pos < len(content): if content[pos:].startswith("```"): end = content.find("```", pos + 3) if end == -1: story.append({"text": content[pos:]}) break block_text = content[pos + 3 : end] lang = "" if "\n" in block_text: first_line, rest = block_text.split("\n", 1) if first_line.strip() and not first_line.strip().startswith(" "): lang = first_line.strip() block_text = rest story.append({"code": {"code": block_text.strip(), "lang": lang}}) pos = end + 3 while pos < len(content) and content[pos] == "\n": pos += 1 continue if content[pos:].startswith(("# ", "## ", "### ", "#### ")): end = content.find("\n", pos) if end == -1: end = len(content) line = content[pos:end] level = 0 for ch in line: if ch == "#": level += 1 else: break tag = f"h{level}" if level <= 4 else "h4" story.append({"header": {"content": line[level:].strip(), "tag": tag}}) pos = end + 1 continue if content[pos:].startswith("> "): end = content.find("\n", pos) if end == -1: end = len(content) quote_text = content[pos + 2 : end] story.append({"blockquote": _parse_inline(quote_text)}) pos = end + 1 continue m = re.match(r"^[-*+]\s+", content[pos:]) if m: items: list[str] = [] while pos < len(content): m2 = re.match(r"^[-*+]\s+(.+)$", content[pos:], re.MULTILINE) if not m2: break items.append(m2.group(1)) end = content.find("\n", pos + m2.end()) if end == -1: pos = len(content) else: pos = end + 1 if items: story.append({"listing": {"type": "unordered", "items": items, "contents": []}}) continue m = re.match(r"^\d+\.\s+", content[pos:]) if m: items: list[str] = [] while pos < len(content): m2 = re.match(r"^\d+\.\s+(.+)$", content[pos:], re.MULTILINE) if not m2: break items.append(m2.group(1)) end = content.find("\n", pos + m2.end()) if end == -1: pos = len(content) else: pos = end + 1 if items: story.append({"listing": {"type": "ordered", "items": items, "contents": []}}) continue m = _RULE_RE.match(content[pos:]) if m: end = content.find("\n", pos) if end == -1: end = len(content) story.append({"block": {"rule": None}}) pos = end + 1 continue end = content.find("\n", pos) if end == -1: end = len(content) line = content[pos:end] if line.strip(): story.append(_parse_inline(line)) pos = end + 1 return story def _parse_inline(text: str) -> dict[str, Any]: remaining = text inlines: list[dict[str, Any]] = [] while remaining: has_match = False earliest = len(remaining) match_type = None match_data = None for m in _BOLD_RE.finditer(remaining): if m.start() < earliest: earliest = m.start() match_type = "bold" match_data = m has_match = True for m in _ITALIC_RE.finditer(remaining): if m.start() < earliest: earliest = m.start() match_type = "italic" match_data = m has_match = True for m in _STRIKE_RE.finditer(remaining): if m.start() < earliest: earliest = m.start() match_type = "strike" match_data = m has_match = True for m in _INLINE_CODE_RE.finditer(remaining): if m.start() < earliest: earliest = m.start() match_type = "code" match_data = m has_match = True for m in _LINK_RE.finditer(remaining): if m.start() < earliest: earliest = m.start() match_type = "link" match_data = m has_match = True for m in _SHIP_RE.finditer(remaining): if m.start() < earliest: earliest = m.start() match_type = "ship_ref" match_data = m has_match = True if not has_match: if remaining.strip(): inlines.append({"text": remaining}) break if earliest > 0: inlines.append({"text": remaining[:earliest]}) if match_type == "bold": inlines.append({"bold": [_parse_inline(match_data.group(1))]}) elif match_type == "italic": inlines.append({"italics": [_parse_inline(match_data.group(1))]}) elif match_type == "strike": inlines.append({"strike": [_parse_inline(match_data.group(1))]}) elif match_type == "code": inlines.append({"inline-code": match_data.group(1)}) elif match_type == "link": inlines.append({"link": {"href": match_data.group(2), "content": match_data.group(1)}}) elif match_type == "ship_ref": inlines.append({"ship": match_data.group(1)}) remaining = remaining[match_data.end() :] if len(inlines) == 1: return inlines[0] has_only_text = all("text" in item for item in inlines) if has_only_text: return {"text": "".join(item["text"] for item in inlines)} return {"text": "".join(_inline_to_text(item) for item in inlines)} def _inline_to_text(item: dict[str, Any]) -> str: if "text" in item: return item["text"] if "bold" in item: inner = item["bold"] if isinstance(inner, list): return "".join(_inline_to_text(i) for i in inner) return str(inner) if "italics" in item: inner = item["italics"] if isinstance(inner, list): return "".join(_inline_to_text(i) for i in inner) return str(inner) if "strike" in item: inner = item["strike"] if isinstance(inner, list): return "".join(_inline_to_text(i) for i in inner) return str(inner) if "inline-code" in item: return str(item["inline-code"]) if "link" in item: link = item["link"] return link.get("content", link.get("href", "")) if isinstance(link, dict) else str(link) if "ship" in item: return item["ship"] return "" def story_to_plain_text(story: list[dict[str, Any]]) -> str: parts: list[str] = [] for item in story: if "text" in item: parts.append(item["text"]) elif "bold" in item: parts.append("".join(_inline_to_text(i) for i in item["bold"])) elif "italics" in item: parts.append("".join(_inline_to_text(i) for i in item["italics"])) elif "strike" in item: parts.append("".join(_inline_to_text(i) for i in item["strike"])) elif "inline-code" in item: parts.append(item["inline-code"]) elif "blockquote" in item: parts.append(_story_to_text_recursive(item["blockquote"])) elif "header" in item: parts.append(item["header"].get("content", "")) elif "code" in item: code_data = item["code"] if isinstance(code_data, dict): parts.append(code_data.get("code", "")) elif "link" in item: link = item["link"] if isinstance(link, dict): parts.append(link.get("content", link.get("href", ""))) elif "break" in item: parts.append("\n") elif "ship" in item: parts.append(item["ship"]) elif "listing" in item: listing = item["listing"] if isinstance(listing, dict): for list_item in listing.get("items", []): parts.append(f"- {list_item}\n") return "".join(parts) def _story_to_text_recursive(items: list[dict[str, Any]]) -> str: parts: list[str] = [] for item in items: if "text" in item: parts.append(item["text"]) elif "bold" in item: parts.append("".join(_inline_to_text(i) for i in item["bold"])) elif "italics" in item: parts.append("".join(_inline_to_text(i) for i in item["italics"])) elif "inline-code" in item: parts.append(item["inline-code"]) elif "link" in item: link = item["link"] parts.append(link.get("content", "") if isinstance(link, dict) else str(link)) elif "break" in item: parts.append("\n") return "".join(parts)