317 lines
11 KiB
Python
317 lines
11 KiB
Python
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"(?<!\w)(~[a-z]{3,}(-[a-z]{3,})*)(?!\w)", re.IGNORECASE)
|
|
_RULE_RE = re.compile(r"^[-*_]{3,}\s*$", re.MULTILINE)
|
|
|
|
|
|
def markdown_to_story(content: str) -> 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)
|