markdown-extractor 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- markdown_extractor/__init__.py +14 -0
- markdown_extractor/blocks.py +359 -0
- markdown_extractor/extractor.py +193 -0
- markdown_extractor/html_renderer.py +150 -0
- markdown_extractor/parser.py +153 -0
- markdown_extractor/py.typed +0 -0
- markdown_extractor/section.py +320 -0
- markdown_extractor/text_renderer.py +83 -0
- markdown_extractor-0.1.0.dist-info/METADATA +676 -0
- markdown_extractor-0.1.0.dist-info/RECORD +13 -0
- markdown_extractor-0.1.0.dist-info/WHEEL +5 -0
- markdown_extractor-0.1.0.dist-info/licenses/LICENSE +201 -0
- markdown_extractor-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Render the parsed block tree to HTML.
|
|
2
|
+
|
|
3
|
+
Pure-Python, zero-dependency renderer for the same Markdown subset that
|
|
4
|
+
:mod:`markdown_extractor.blocks` understands: paragraphs, ordered/unordered
|
|
5
|
+
lists with nesting, code fences, blockquotes — plus the common inline
|
|
6
|
+
constructs (``**bold**``, ``*em*``, ``` `code` ```, ``[text](url)``,
|
|
7
|
+
````).
|
|
8
|
+
|
|
9
|
+
The output is plain HTML5 with no styling. It is intentionally minimal:
|
|
10
|
+
``markdown-extractor``'s job is structural extraction, not pretty rendering.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from html import escape
|
|
17
|
+
from typing import Iterable, List
|
|
18
|
+
|
|
19
|
+
from markdown_extractor.blocks import Block
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def render(blocks: Iterable[Block]) -> str:
|
|
23
|
+
"""Render a sequence of blocks to an HTML fragment."""
|
|
24
|
+
return "\n".join(_render_block(b) for b in blocks)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _render_block(block: Block) -> str:
|
|
28
|
+
if block.kind == "paragraph":
|
|
29
|
+
return f"<p>{_inline(block.text)}</p>"
|
|
30
|
+
if block.kind == "code":
|
|
31
|
+
cls = f' class="language-{escape(block.info)}"' if block.info else ""
|
|
32
|
+
return f"<pre><code{cls}>{escape(block.text)}</code></pre>"
|
|
33
|
+
if block.kind == "blockquote":
|
|
34
|
+
inner = render(block.children) if block.children else f"<p>{_inline(block.text)}</p>"
|
|
35
|
+
return f"<blockquote>\n{inner}\n</blockquote>"
|
|
36
|
+
if block.kind in ("list", "ordered_list"):
|
|
37
|
+
tag = "ol" if block.kind == "ordered_list" else "ul"
|
|
38
|
+
items = "\n".join(_render_block(item) for item in block.children)
|
|
39
|
+
return f"<{tag}>\n{items}\n</{tag}>"
|
|
40
|
+
if block.kind == "list_item":
|
|
41
|
+
body = _inline(block.text)
|
|
42
|
+
if block.children:
|
|
43
|
+
nested = render(block.children)
|
|
44
|
+
return f"<li>{body}\n{nested}\n</li>"
|
|
45
|
+
return f"<li>{body}</li>"
|
|
46
|
+
return f"<div>{_inline(block.text)}</div>"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------- inline
|
|
50
|
+
|
|
51
|
+
# Order matters: replace inline code first (so its contents are not further
|
|
52
|
+
# transformed), then images, links, bold, em.
|
|
53
|
+
_CODE_RE = re.compile(r"`([^`]+)`")
|
|
54
|
+
_IMG_RE = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)\)")
|
|
55
|
+
_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)\s]+)\)")
|
|
56
|
+
_BOLD_RE = re.compile(r"\*\*([^*]+)\*\*")
|
|
57
|
+
_EM_RE = re.compile(r"(?<![*\w])\*([^*\n]+?)\*(?!\w)")
|
|
58
|
+
_EM_UNDER_RE = re.compile(r"(?<![\w_])_([^_\n]+?)_(?!\w)")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _inline(text: str) -> str:
|
|
62
|
+
"""Convert the inline-formatting subset to HTML.
|
|
63
|
+
|
|
64
|
+
Inline ``code`` content is escaped and stashed before any other rule
|
|
65
|
+
runs, so backticks shield their contents from bold/em/link parsing.
|
|
66
|
+
"""
|
|
67
|
+
if not text:
|
|
68
|
+
return ""
|
|
69
|
+
|
|
70
|
+
placeholders: List[str] = []
|
|
71
|
+
|
|
72
|
+
def stash(html: str) -> str:
|
|
73
|
+
placeholders.append(html)
|
|
74
|
+
return f"\x00{len(placeholders) - 1}\x00"
|
|
75
|
+
|
|
76
|
+
def repl_code(m: re.Match) -> str:
|
|
77
|
+
return stash(f"<code>{escape(m.group(1))}</code>")
|
|
78
|
+
|
|
79
|
+
out = _CODE_RE.sub(repl_code, text)
|
|
80
|
+
out = escape(out, quote=False)
|
|
81
|
+
|
|
82
|
+
def repl_img(m: re.Match) -> str:
|
|
83
|
+
alt = escape(m.group(1), quote=True)
|
|
84
|
+
src = escape(m.group(2), quote=True)
|
|
85
|
+
return stash(f'<img src="{src}" alt="{alt}">')
|
|
86
|
+
|
|
87
|
+
def repl_link(m: re.Match) -> str:
|
|
88
|
+
label = m.group(1)
|
|
89
|
+
href = escape(m.group(2), quote=True)
|
|
90
|
+
return stash(f'<a href="{href}">{label}</a>')
|
|
91
|
+
|
|
92
|
+
# The escape pass replaced angle brackets — restore the markers our
|
|
93
|
+
# regexes need by working on the escaped string for img/link too.
|
|
94
|
+
out = _IMG_RE.sub(repl_img, out)
|
|
95
|
+
out = _LINK_RE.sub(repl_link, out)
|
|
96
|
+
out = _BOLD_RE.sub(lambda m: f"<strong>{m.group(1)}</strong>", out)
|
|
97
|
+
out = _EM_RE.sub(lambda m: f"<em>{m.group(1)}</em>", out)
|
|
98
|
+
out = _EM_UNDER_RE.sub(lambda m: f"<em>{m.group(1)}</em>", out)
|
|
99
|
+
|
|
100
|
+
# Restore stashed HTML.
|
|
101
|
+
def unstash(m: re.Match) -> str:
|
|
102
|
+
return placeholders[int(m.group(1))]
|
|
103
|
+
|
|
104
|
+
out = re.sub(r"\x00(\d+)\x00", unstash, out)
|
|
105
|
+
return out
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def query_xpath(html: str, xpath: str, as_text: bool = False) -> List[str]:
|
|
109
|
+
"""Run ``xpath`` over ``html`` and return the matched fragments.
|
|
110
|
+
|
|
111
|
+
Requires the ``lxml`` extra (``pip install markdown-extractor[xpath]``).
|
|
112
|
+
By default each element match is returned as an HTML string;
|
|
113
|
+
string/attribute matches are returned as-is.
|
|
114
|
+
|
|
115
|
+
With ``as_text=True``, element matches are flattened to their text
|
|
116
|
+
content (recursively — so ``<li><strong>Bold</strong> rest</li>``
|
|
117
|
+
yields ``"Bold rest"``). Use this when you want the data inside the
|
|
118
|
+
element rather than the markup. Compare to writing ``/text()`` in
|
|
119
|
+
the XPath itself, which only collects *direct* text children and
|
|
120
|
+
skips text nested inside inline tags.
|
|
121
|
+
"""
|
|
122
|
+
if not html:
|
|
123
|
+
return []
|
|
124
|
+
try:
|
|
125
|
+
from lxml import etree, html as lxml_html
|
|
126
|
+
except ImportError as e: # pragma: no cover - only hit without lxml
|
|
127
|
+
raise ModuleNotFoundError(
|
|
128
|
+
"XPath queries require the 'lxml' package. "
|
|
129
|
+
"Install with: pip install markdown-extractor[xpath]"
|
|
130
|
+
) from e
|
|
131
|
+
|
|
132
|
+
fragment = lxml_html.fragment_fromstring(html, create_parent="div")
|
|
133
|
+
results = fragment.xpath(xpath)
|
|
134
|
+
out: List[str] = []
|
|
135
|
+
for r in results:
|
|
136
|
+
if isinstance(r, str):
|
|
137
|
+
out.append(r)
|
|
138
|
+
elif isinstance(r, etree._Element):
|
|
139
|
+
if as_text:
|
|
140
|
+
out.append(r.text_content())
|
|
141
|
+
else:
|
|
142
|
+
# ``with_tail=False`` excludes sibling text after the closing
|
|
143
|
+
# tag — callers asking for an element fragment want just the
|
|
144
|
+
# element, not its trailing context.
|
|
145
|
+
out.append(
|
|
146
|
+
lxml_html.tostring(r, encoding="unicode", with_tail=False).rstrip()
|
|
147
|
+
)
|
|
148
|
+
else:
|
|
149
|
+
out.append(str(r))
|
|
150
|
+
return out
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Header detection for Markdown documents.
|
|
2
|
+
|
|
3
|
+
The parser walks the document line by line while tracking which "block
|
|
4
|
+
context" each line belongs to. Headers found inside fenced code blocks,
|
|
5
|
+
math blocks, tables, or YAML front matter are intentionally ignored.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import List, NamedTuple, Tuple
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Header(NamedTuple):
|
|
15
|
+
"""A single header occurrence detected in the source document."""
|
|
16
|
+
|
|
17
|
+
line: int
|
|
18
|
+
level: int
|
|
19
|
+
title: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ATX header: optional indentation, 1–6 #s, required space, title, optional
|
|
23
|
+
# trailing #s. We allow any amount of leading whitespace because the spec
|
|
24
|
+
# explicitly calls for "indentation support".
|
|
25
|
+
_ATX_RE = re.compile(r"^[ \t]*(#{1,6})[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$")
|
|
26
|
+
|
|
27
|
+
# Fenced code block opener: 3+ backticks or 3+ tildes with up to 3 leading
|
|
28
|
+
# spaces (CommonMark restricts the indent of a fence to <4 spaces).
|
|
29
|
+
_FENCE_RE = re.compile(r"^[ ]{0,3}(`{3,}|~{3,})")
|
|
30
|
+
|
|
31
|
+
# Setext underlines.
|
|
32
|
+
_SETEXT_H1_RE = re.compile(r"^[ ]{0,3}=+[ \t]*$")
|
|
33
|
+
_SETEXT_H2_RE = re.compile(r"^[ ]{0,3}-+[ \t]*$")
|
|
34
|
+
|
|
35
|
+
# Quick check for "looks like a list item" — used to disambiguate setext h2
|
|
36
|
+
# from a regular --- horizontal rule below a paragraph.
|
|
37
|
+
_LIST_RE = re.compile(r"^[ \t]*([-*+]|\d+[.)])[ \t]")
|
|
38
|
+
|
|
39
|
+
# Table separator row: |---|---| or :---:|---: etc.
|
|
40
|
+
_TABLE_SEP_RE = re.compile(r"^[ \t]*\|?[ \t]*:?-+:?([ \t]*\|[ \t]*:?-+:?)+[ \t]*\|?[ \t]*$")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _closing_fence_re(fence_char: str, fence_len: int) -> re.Pattern[str]:
|
|
44
|
+
return re.compile(
|
|
45
|
+
r"^[ ]{0,3}" + re.escape(fence_char) + r"{" + str(fence_len) + r",}[ \t]*$"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse(content: str) -> Tuple[List[Header], List[str]]:
|
|
50
|
+
"""Return ``(headers, lines)`` for ``content``.
|
|
51
|
+
|
|
52
|
+
``lines`` is the document split on ``\n`` (newlines stripped) and is
|
|
53
|
+
shared with :class:`markdown_extractor.section.Section` so each section can
|
|
54
|
+
rebuild its own slice of the source on demand.
|
|
55
|
+
"""
|
|
56
|
+
lines = content.split("\n")
|
|
57
|
+
headers: List[Header] = []
|
|
58
|
+
|
|
59
|
+
in_code = False
|
|
60
|
+
fence_char = ""
|
|
61
|
+
fence_len = 0
|
|
62
|
+
closing_re: re.Pattern[str] | None = None
|
|
63
|
+
|
|
64
|
+
in_math = False
|
|
65
|
+
|
|
66
|
+
in_yaml = False
|
|
67
|
+
yaml_checked = False
|
|
68
|
+
|
|
69
|
+
in_table = False
|
|
70
|
+
|
|
71
|
+
for i, line in enumerate(lines):
|
|
72
|
+
stripped = line.strip()
|
|
73
|
+
|
|
74
|
+
# ---------- YAML front matter (only at the very top of the file)
|
|
75
|
+
if not yaml_checked:
|
|
76
|
+
yaml_checked = True
|
|
77
|
+
if stripped == "---":
|
|
78
|
+
in_yaml = True
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
if in_yaml:
|
|
82
|
+
if stripped == "---" or stripped == "...":
|
|
83
|
+
in_yaml = False
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
# ---------- Fenced code blocks
|
|
87
|
+
if in_code:
|
|
88
|
+
assert closing_re is not None
|
|
89
|
+
if closing_re.match(line):
|
|
90
|
+
in_code = False
|
|
91
|
+
closing_re = None
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
m_fence = _FENCE_RE.match(line)
|
|
95
|
+
if m_fence:
|
|
96
|
+
in_code = True
|
|
97
|
+
marker = m_fence.group(1)
|
|
98
|
+
fence_char = marker[0]
|
|
99
|
+
fence_len = len(marker)
|
|
100
|
+
closing_re = _closing_fence_re(fence_char, fence_len)
|
|
101
|
+
in_table = False
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
# ---------- Math blocks ($$ ... $$)
|
|
105
|
+
if stripped == "$$":
|
|
106
|
+
in_math = not in_math
|
|
107
|
+
in_table = False
|
|
108
|
+
continue
|
|
109
|
+
if in_math:
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
# ---------- Tables
|
|
113
|
+
# A table block runs from the first pipe-line through a blank line.
|
|
114
|
+
# Headers inside a table are extremely unusual but we still skip
|
|
115
|
+
# them to honour the spec.
|
|
116
|
+
if not stripped:
|
|
117
|
+
in_table = False
|
|
118
|
+
elif _TABLE_SEP_RE.match(line) or "|" in stripped:
|
|
119
|
+
# Stay in table mode while we see pipe-lines or separators.
|
|
120
|
+
if in_table or _TABLE_SEP_RE.match(line) or stripped.startswith("|"):
|
|
121
|
+
in_table = True
|
|
122
|
+
# Continue: a pipe-line itself can never be a header.
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
if in_table:
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
# ---------- ATX headers
|
|
129
|
+
m_atx = _ATX_RE.match(line)
|
|
130
|
+
if m_atx:
|
|
131
|
+
level = len(m_atx.group(1))
|
|
132
|
+
title = m_atx.group(2).strip().rstrip("#").rstrip()
|
|
133
|
+
if title:
|
|
134
|
+
headers.append(Header(line=i, level=level, title=title))
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
# ---------- Setext headers (=== / ---)
|
|
138
|
+
# The underline lives on the *current* line; the title is the
|
|
139
|
+
# previous line. We only accept it when the previous line is
|
|
140
|
+
# genuine paragraph text (not a list item, not blank, and not
|
|
141
|
+
# already classified as something else).
|
|
142
|
+
if i > 0 and stripped:
|
|
143
|
+
prev = lines[i - 1]
|
|
144
|
+
prev_stripped = prev.strip()
|
|
145
|
+
if prev_stripped and not _LIST_RE.match(prev) and not _ATX_RE.match(prev):
|
|
146
|
+
if _SETEXT_H1_RE.match(line):
|
|
147
|
+
headers.append(Header(line=i - 1, level=1, title=prev_stripped))
|
|
148
|
+
continue
|
|
149
|
+
if _SETEXT_H2_RE.match(line) and prev_stripped != "---":
|
|
150
|
+
headers.append(Header(line=i - 1, level=2, title=prev_stripped))
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
return headers, lines
|
|
File without changes
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""The :class:`Section` node — one entry in the parsed header tree."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Dict, Iterator, List, Optional, Union
|
|
7
|
+
|
|
8
|
+
from markdown_extractor.blocks import Block, _null_block, flatten, parse_blocks
|
|
9
|
+
from markdown_extractor.html_renderer import query_xpath, render
|
|
10
|
+
from markdown_extractor.text_renderer import render_text
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Section:
|
|
14
|
+
"""A single header (and its body) in a parsed Markdown document.
|
|
15
|
+
|
|
16
|
+
A ``Section`` lazily slices the original document on access — there is
|
|
17
|
+
no per-section copy of the source, so the tree is cheap to hold even
|
|
18
|
+
for very large documents.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
__slots__ = (
|
|
22
|
+
"title",
|
|
23
|
+
"level",
|
|
24
|
+
"line_start",
|
|
25
|
+
"line_end",
|
|
26
|
+
"parent",
|
|
27
|
+
"children",
|
|
28
|
+
"_lines",
|
|
29
|
+
"_blocks_cache",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
title: str,
|
|
35
|
+
level: int,
|
|
36
|
+
line_start: int,
|
|
37
|
+
line_end: Optional[int] = None,
|
|
38
|
+
parent: Optional["Section"] = None,
|
|
39
|
+
lines: Optional[List[str]] = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
self.title = title
|
|
42
|
+
self.level = level
|
|
43
|
+
self.line_start = line_start
|
|
44
|
+
self.line_end = line_end
|
|
45
|
+
self.parent = parent
|
|
46
|
+
self.children: List["Section"] = []
|
|
47
|
+
self._lines = lines
|
|
48
|
+
self._blocks_cache: Optional[List[Block]] = None
|
|
49
|
+
|
|
50
|
+
# ------------------------------------------------------------------ slices
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def content(self) -> str:
|
|
54
|
+
"""The header line plus everything beneath it, including subsections."""
|
|
55
|
+
if self._lines is None or self.line_end is None:
|
|
56
|
+
return ""
|
|
57
|
+
return "\n".join(self._lines[self.line_start : self.line_end])
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def body(self) -> str:
|
|
61
|
+
"""The section content with the header line removed.
|
|
62
|
+
|
|
63
|
+
For the synthetic root (level 0) this is identical to :attr:`content`
|
|
64
|
+
because the root has no header line to strip.
|
|
65
|
+
"""
|
|
66
|
+
if self._lines is None or self.line_end is None:
|
|
67
|
+
return ""
|
|
68
|
+
start = self.line_start if self.level == 0 else self.line_start + 1
|
|
69
|
+
return "\n".join(self._lines[start : self.line_end])
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def text(self) -> str:
|
|
73
|
+
"""Just this section's own prose — the header is dropped and any
|
|
74
|
+
nested subsections are excluded."""
|
|
75
|
+
if self._lines is None or self.line_end is None:
|
|
76
|
+
return ""
|
|
77
|
+
start = self.line_start if self.level == 0 else self.line_start + 1
|
|
78
|
+
end = self.children[0].line_start if self.children else self.line_end
|
|
79
|
+
return "\n".join(self._lines[start:end])
|
|
80
|
+
|
|
81
|
+
# ------------------------------------------------------------------ navigation
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def path(self) -> List[str]:
|
|
85
|
+
"""Titles from the topmost ancestor down to this section (root excluded)."""
|
|
86
|
+
result: List[str] = []
|
|
87
|
+
node: Optional[Section] = self
|
|
88
|
+
while node is not None and node.level > 0:
|
|
89
|
+
result.append(node.title)
|
|
90
|
+
node = node.parent
|
|
91
|
+
result.reverse()
|
|
92
|
+
return result
|
|
93
|
+
|
|
94
|
+
def list(self) -> List[str]:
|
|
95
|
+
"""Titles of immediate child sections."""
|
|
96
|
+
return [c.title for c in self.children]
|
|
97
|
+
|
|
98
|
+
def get_section(self, *path: str) -> "Section":
|
|
99
|
+
"""Walk a sequence of child titles, e.g. ``s.get_section("A", "B")``.
|
|
100
|
+
|
|
101
|
+
Strict — raises :class:`KeyError` if any title is missing. For
|
|
102
|
+
an error-free version that flows through to empty results, use
|
|
103
|
+
:meth:`get`.
|
|
104
|
+
"""
|
|
105
|
+
node: Section = self
|
|
106
|
+
for title in path:
|
|
107
|
+
node = node[title]
|
|
108
|
+
return node
|
|
109
|
+
|
|
110
|
+
def get(self, *path: str) -> "Section":
|
|
111
|
+
"""Soft path walk — returns a *null* section on miss instead of raising.
|
|
112
|
+
|
|
113
|
+
``e.get("Foo", "Bar").to_list()`` returns ``[]`` (not a ``KeyError``)
|
|
114
|
+
if either ``"Foo"`` or ``"Bar"`` is missing. The returned null
|
|
115
|
+
section is falsy (``bool(s) is False``) and its ``to_list``,
|
|
116
|
+
``to_dict``, ``to_json``, ``to_html``, and ``to_text`` methods
|
|
117
|
+
return empty values, so chains stay safe.
|
|
118
|
+
|
|
119
|
+
Use ``[]`` (or :meth:`get_section`) when you want missing keys
|
|
120
|
+
to fail loudly.
|
|
121
|
+
"""
|
|
122
|
+
node: Section = self
|
|
123
|
+
for title in path:
|
|
124
|
+
if not node: # already null — keep flowing
|
|
125
|
+
return _null_section()
|
|
126
|
+
found: Optional[Section] = None
|
|
127
|
+
for child in node.children:
|
|
128
|
+
if child.title == title:
|
|
129
|
+
found = child
|
|
130
|
+
break
|
|
131
|
+
if found is None:
|
|
132
|
+
return _null_section()
|
|
133
|
+
node = found
|
|
134
|
+
return node
|
|
135
|
+
|
|
136
|
+
def find(self, title: str) -> List["Section"]:
|
|
137
|
+
"""All descendants whose title equals ``title`` (depth-first order)."""
|
|
138
|
+
results: List[Section] = []
|
|
139
|
+
for child in self.children:
|
|
140
|
+
if child.title == title:
|
|
141
|
+
results.append(child)
|
|
142
|
+
results.extend(child.find(title))
|
|
143
|
+
return results
|
|
144
|
+
|
|
145
|
+
def walk(self) -> Iterator["Section"]:
|
|
146
|
+
"""Yield this section and every descendant, depth-first."""
|
|
147
|
+
yield self
|
|
148
|
+
for child in self.children:
|
|
149
|
+
yield from child.walk()
|
|
150
|
+
|
|
151
|
+
# ------------------------------------------------------------------ body blocks
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def blocks(self) -> List[Block]:
|
|
155
|
+
"""Lazy parse of this section's own prose into a block tree.
|
|
156
|
+
|
|
157
|
+
The block tree covers paragraphs, ordered/unordered lists with
|
|
158
|
+
nested items, code fences, and blockquotes. Header subsections
|
|
159
|
+
of this section are *not* included — those live in
|
|
160
|
+
:attr:`children`.
|
|
161
|
+
"""
|
|
162
|
+
if self._blocks_cache is None:
|
|
163
|
+
self._blocks_cache = parse_blocks(self.text)
|
|
164
|
+
return self._blocks_cache
|
|
165
|
+
|
|
166
|
+
def to_list(self) -> List[str]:
|
|
167
|
+
"""Flatten the body into a list of strings, one per top-level
|
|
168
|
+
block (or one per top-level list item if the body is a list).
|
|
169
|
+
|
|
170
|
+
Useful when you want the section's body as data — e.g. ``Overview``
|
|
171
|
+
bullets as a list of feature strings — rather than as a header
|
|
172
|
+
title roster (which is what :meth:`list` returns).
|
|
173
|
+
"""
|
|
174
|
+
return flatten(self.blocks)
|
|
175
|
+
|
|
176
|
+
def block(self, *indices: int) -> Block:
|
|
177
|
+
"""Soft index walk into this section's body block tree.
|
|
178
|
+
|
|
179
|
+
The first index addresses :attr:`blocks`; subsequent indices walk
|
|
180
|
+
into ``.children``. Returns a *null Block* (whose ``text_plain``
|
|
181
|
+
is ``""``) if any index is out of range, so chains like
|
|
182
|
+
``section.block(99, 0).text_plain`` stay safe.
|
|
183
|
+
|
|
184
|
+
``section.block(1, 1).text_plain`` is the soft equivalent of
|
|
185
|
+
``section.blocks[1].children[1].text_plain``.
|
|
186
|
+
"""
|
|
187
|
+
if not indices:
|
|
188
|
+
return _null_block()
|
|
189
|
+
blocks = self.blocks
|
|
190
|
+
head, *rest = indices
|
|
191
|
+
n = len(blocks)
|
|
192
|
+
if not n or head < -n or head >= n:
|
|
193
|
+
return _null_block()
|
|
194
|
+
return blocks[head].get(*rest)
|
|
195
|
+
|
|
196
|
+
def to_text(self) -> str:
|
|
197
|
+
"""Render the body to plain text with Markdown markers stripped.
|
|
198
|
+
|
|
199
|
+
Bullets become ``- `` lines, ordered items become ``1. `` lines,
|
|
200
|
+
nested children indent by four spaces, code blocks are kept
|
|
201
|
+
verbatim, and inline ``**bold**`` / ``*em*`` / ``` `code` `` /
|
|
202
|
+
links / images are reduced to their visible text.
|
|
203
|
+
"""
|
|
204
|
+
return render_text(self.blocks)
|
|
205
|
+
|
|
206
|
+
# ------------------------------------------------------------------ serialisation
|
|
207
|
+
|
|
208
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
209
|
+
"""Convert to a JSON-friendly nested dict.
|
|
210
|
+
|
|
211
|
+
Includes both header subsections (``children``) and the body
|
|
212
|
+
block tree (``blocks``). The ``blocks`` field captures bullet
|
|
213
|
+
lists, paragraphs, and indented continuations as nested nodes.
|
|
214
|
+
"""
|
|
215
|
+
return {
|
|
216
|
+
"title": self.title,
|
|
217
|
+
"level": self.level,
|
|
218
|
+
"text": self.text,
|
|
219
|
+
"blocks": [b.to_dict() for b in self.blocks],
|
|
220
|
+
"children": [c.to_dict() for c in self.children],
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
def to_json(self, **kwargs: Any) -> str:
|
|
224
|
+
"""Shorthand for ``json.dumps(self.to_dict(), **kwargs)``."""
|
|
225
|
+
return json.dumps(self.to_dict(), **kwargs)
|
|
226
|
+
|
|
227
|
+
def to_html(
|
|
228
|
+
self, xpath: Optional[str] = None, as_text: bool = False
|
|
229
|
+
) -> Union[str, List[str]]:
|
|
230
|
+
"""Render this section's body as an HTML fragment.
|
|
231
|
+
|
|
232
|
+
Without ``xpath``, returns the full HTML string. With ``xpath``,
|
|
233
|
+
returns a list of matched fragments — each is an HTML string for
|
|
234
|
+
element matches, or the raw value for string / attribute matches.
|
|
235
|
+
|
|
236
|
+
Pass ``as_text=True`` to flatten element matches to their text
|
|
237
|
+
content (recursively, so inline tags like ``<strong>`` are
|
|
238
|
+
unwrapped). Useful when you want the data inside the element
|
|
239
|
+
rather than the markup. Has no effect when ``xpath`` is ``None``.
|
|
240
|
+
|
|
241
|
+
XPath support requires the optional ``lxml`` extra::
|
|
242
|
+
|
|
243
|
+
pip install markdown-extractor[xpath]
|
|
244
|
+
"""
|
|
245
|
+
html = render(self.blocks)
|
|
246
|
+
if xpath is None:
|
|
247
|
+
return html
|
|
248
|
+
return query_xpath(html, xpath, as_text=as_text)
|
|
249
|
+
|
|
250
|
+
def tree(self, _indent: int = 0) -> str:
|
|
251
|
+
"""ASCII tree rendering of this section and its descendants."""
|
|
252
|
+
label = "<root>" if self.level == 0 else f"{'#' * self.level} {self.title}"
|
|
253
|
+
out = " " * _indent + label
|
|
254
|
+
for child in self.children:
|
|
255
|
+
out += "\n" + child.tree(_indent + 1)
|
|
256
|
+
return out
|
|
257
|
+
|
|
258
|
+
# ------------------------------------------------------------------ dunder
|
|
259
|
+
|
|
260
|
+
def __getitem__(self, key: Union[str, int]) -> "Section":
|
|
261
|
+
if isinstance(key, int):
|
|
262
|
+
return self.children[key]
|
|
263
|
+
for child in self.children:
|
|
264
|
+
if child.title == key:
|
|
265
|
+
return child
|
|
266
|
+
raise KeyError(
|
|
267
|
+
f"Section {key!r} not found. Available children: {self.list()!r}"
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def __contains__(self, key: object) -> bool:
|
|
271
|
+
if isinstance(key, str):
|
|
272
|
+
return any(child.title == key for child in self.children)
|
|
273
|
+
return False
|
|
274
|
+
|
|
275
|
+
def __iter__(self) -> Iterator["Section"]:
|
|
276
|
+
return iter(self.children)
|
|
277
|
+
|
|
278
|
+
def __len__(self) -> int:
|
|
279
|
+
return len(self.children)
|
|
280
|
+
|
|
281
|
+
def __str__(self) -> str:
|
|
282
|
+
return self.content
|
|
283
|
+
|
|
284
|
+
def __bool__(self) -> bool:
|
|
285
|
+
"""A non-null section is truthy. The sentinel returned by
|
|
286
|
+
:meth:`get` on a missing path is the only falsy ``Section``."""
|
|
287
|
+
return self._lines is not None
|
|
288
|
+
|
|
289
|
+
def __repr__(self) -> str:
|
|
290
|
+
if self._lines is None:
|
|
291
|
+
return "Section(<null>)"
|
|
292
|
+
return (
|
|
293
|
+
f"Section(title={self.title!r}, level={self.level}, "
|
|
294
|
+
f"children={len(self.children)})"
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ---------------------------------------------------------------- null sentinel
|
|
299
|
+
|
|
300
|
+
_NULL_SECTION: Optional[Section] = None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _null_section() -> Section:
|
|
304
|
+
"""Cached null-section sentinel returned by :meth:`Section.get` on miss.
|
|
305
|
+
|
|
306
|
+
Behaviour summary:
|
|
307
|
+
- ``bool(s)`` is ``False``
|
|
308
|
+
- ``to_list()`` → ``[]``
|
|
309
|
+
- ``to_dict()`` → ``{"title": "", "level": 0, "text": "", "blocks": [], "children": []}``
|
|
310
|
+
- ``to_json()`` → JSON of the above
|
|
311
|
+
- ``to_html()`` → ``""`` (with ``xpath=...`` → ``[]``)
|
|
312
|
+
- ``to_text()`` → ``""``
|
|
313
|
+
- ``get(*more)`` → keeps returning this sentinel
|
|
314
|
+
"""
|
|
315
|
+
global _NULL_SECTION
|
|
316
|
+
if _NULL_SECTION is None:
|
|
317
|
+
_NULL_SECTION = Section(
|
|
318
|
+
title="", level=0, line_start=0, line_end=None, lines=None
|
|
319
|
+
)
|
|
320
|
+
return _NULL_SECTION
|