docspan 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.
- docspan/__init__.py +3 -0
- docspan/__main__.py +0 -0
- docspan/backends/__init__.py +19 -0
- docspan/backends/base.py +85 -0
- docspan/backends/confluence/__init__.py +0 -0
- docspan/backends/confluence/adf/__init__.py +14 -0
- docspan/backends/confluence/adf/comparator.py +427 -0
- docspan/backends/confluence/adf/converter.py +119 -0
- docspan/backends/confluence/adf/converters.py +1449 -0
- docspan/backends/confluence/adf/interfaces.py +191 -0
- docspan/backends/confluence/adf/nodes.py +2085 -0
- docspan/backends/confluence/adf/parser.py +400 -0
- docspan/backends/confluence/adf/validators.py +161 -0
- docspan/backends/confluence/adf/visitors.py +495 -0
- docspan/backends/confluence/backend.py +227 -0
- docspan/backends/confluence/client.py +44 -0
- docspan/backends/confluence/config/__init__.py +21 -0
- docspan/backends/confluence/config/loader.py +107 -0
- docspan/backends/confluence/config/models.py +167 -0
- docspan/backends/confluence/config/validation.py +297 -0
- docspan/backends/confluence/markdown/__init__.py +22 -0
- docspan/backends/confluence/markdown/ast.py +819 -0
- docspan/backends/confluence/markdown/extensions/__init__.py +5 -0
- docspan/backends/confluence/markdown/extensions/frontmatter.py +80 -0
- docspan/backends/confluence/markdown/extensions/mermaid.py +64 -0
- docspan/backends/confluence/markdown/extensions/wikilinks.py +179 -0
- docspan/backends/confluence/markdown/inline_parser.py +495 -0
- docspan/backends/confluence/markdown/parser.py +1006 -0
- docspan/backends/confluence/models/__init__.py +18 -0
- docspan/backends/confluence/models/markdown_file.py +402 -0
- docspan/backends/confluence/models/page.py +212 -0
- docspan/backends/confluence/models/path_utils.py +34 -0
- docspan/backends/confluence/models/results.py +28 -0
- docspan/backends/confluence/models/sync_status.py +382 -0
- docspan/backends/confluence/services/__init__.py +0 -0
- docspan/backends/confluence/services/confluence/__init__.py +40 -0
- docspan/backends/confluence/services/confluence/attachment_client.py +147 -0
- docspan/backends/confluence/services/confluence/base_client.py +420 -0
- docspan/backends/confluence/services/confluence/client.py +376 -0
- docspan/backends/confluence/services/confluence/comment_client.py +682 -0
- docspan/backends/confluence/services/confluence/crawler.py +587 -0
- docspan/backends/confluence/services/confluence/label_client.py +130 -0
- docspan/backends/confluence/services/confluence/page_client.py +1288 -0
- docspan/backends/confluence/services/confluence/space_client.py +179 -0
- docspan/backends/confluence/services/confluence/url_parser.py +106 -0
- docspan/backends/google_docs/__init__.py +0 -0
- docspan/backends/google_docs/auth.py +143 -0
- docspan/backends/google_docs/backend.py +140 -0
- docspan/backends/google_docs/client.py +665 -0
- docspan/backends/google_docs/converter.py +471 -0
- docspan/backends/google_docs/docs_request_builder.py +232 -0
- docspan/backends/google_docs/docs_structure_parser.py +120 -0
- docspan/backends/google_docs/markdown_to_paragraph_parser.py +145 -0
- docspan/cli/__init__.py +0 -0
- docspan/cli/main.py +408 -0
- docspan/config.py +62 -0
- docspan/core/__init__.py +49 -0
- docspan/core/merge.py +30 -0
- docspan/core/orchestrator.py +332 -0
- docspan/core/paths.py +8 -0
- docspan/core/state.py +53 -0
- docspan-0.1.0.dist-info/METADATA +273 -0
- docspan-0.1.0.dist-info/RECORD +65 -0
- docspan-0.1.0.dist-info/WHEEL +4 -0
- docspan-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,1006 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Markdown parser for converting Markdown content to an AST.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, Callable, Dict, List, Match, Optional, Pattern, Tuple
|
|
7
|
+
|
|
8
|
+
from docspan.backends.confluence.markdown.ast import (
|
|
9
|
+
BlockquoteNode,
|
|
10
|
+
BulletListNode,
|
|
11
|
+
CodeBlockMacroNode,
|
|
12
|
+
CodeBlockNode,
|
|
13
|
+
ExcerptNode,
|
|
14
|
+
ExpandMacroNode,
|
|
15
|
+
ExpandNode,
|
|
16
|
+
HeadingNode,
|
|
17
|
+
HorizontalRuleNode,
|
|
18
|
+
InfoNode,
|
|
19
|
+
LayoutColumnNode,
|
|
20
|
+
LayoutSectionNode,
|
|
21
|
+
ListItemNode,
|
|
22
|
+
MarkdownNode,
|
|
23
|
+
MermaidNode,
|
|
24
|
+
NoteNode,
|
|
25
|
+
OrderedListNode,
|
|
26
|
+
ParagraphNode,
|
|
27
|
+
TableNode,
|
|
28
|
+
TaskItemNode,
|
|
29
|
+
TaskListNode,
|
|
30
|
+
URLEmbedNode,
|
|
31
|
+
WarningNode,
|
|
32
|
+
)
|
|
33
|
+
from docspan.backends.confluence.markdown.extensions.frontmatter import FrontmatterParser
|
|
34
|
+
from docspan.backends.confluence.markdown.inline_parser import InlineParser
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MarkdownParser:
|
|
38
|
+
"""
|
|
39
|
+
Parser for Markdown content.
|
|
40
|
+
|
|
41
|
+
Parses Markdown into a tree of MarkdownNode objects that can be
|
|
42
|
+
converted to Atlassian Document Format (ADF).
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self) -> None:
|
|
46
|
+
"""Initialize the parser."""
|
|
47
|
+
self._extensions = []
|
|
48
|
+
self._block_parsers: Dict[str, Tuple[Pattern, Callable[[Match, str], MarkdownNode]]] = {}
|
|
49
|
+
self._inline_parser = InlineParser()
|
|
50
|
+
|
|
51
|
+
# Register default block parsers
|
|
52
|
+
self._register_block_parsers()
|
|
53
|
+
|
|
54
|
+
def register_extension(self, extension: Any) -> None:
|
|
55
|
+
"""
|
|
56
|
+
Register an extension to modify the parsing behavior.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
extension: Extension instance
|
|
60
|
+
"""
|
|
61
|
+
self._extensions.append(extension)
|
|
62
|
+
|
|
63
|
+
def parse(self, content: str) -> List[MarkdownNode]:
|
|
64
|
+
"""
|
|
65
|
+
Parse Markdown content into an AST.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
content: Markdown content to parse
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
List of MarkdownNode objects representing the AST
|
|
72
|
+
"""
|
|
73
|
+
# Extract frontmatter if present
|
|
74
|
+
_, content = FrontmatterParser.extract(content)
|
|
75
|
+
|
|
76
|
+
# Split content into blocks
|
|
77
|
+
blocks = self._split_blocks(content)
|
|
78
|
+
|
|
79
|
+
# Parse blocks
|
|
80
|
+
nodes = []
|
|
81
|
+
for block in blocks:
|
|
82
|
+
if not block.strip():
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
node = self._parse_block(block)
|
|
86
|
+
if node:
|
|
87
|
+
nodes.append(node)
|
|
88
|
+
|
|
89
|
+
return nodes
|
|
90
|
+
|
|
91
|
+
def _dedent_lines(self, lines: List[str]) -> List[str]:
|
|
92
|
+
"""
|
|
93
|
+
Remove common leading whitespace from lines.
|
|
94
|
+
|
|
95
|
+
This is used to normalize indented content before recursive parsing,
|
|
96
|
+
allowing indented list markers to match list patterns that expect
|
|
97
|
+
content at the start of the line.
|
|
98
|
+
|
|
99
|
+
For list items, the first line typically has no indentation (it's the
|
|
100
|
+
item text after the bullet), while continuation lines are indented.
|
|
101
|
+
We want to remove the common indentation from continuation lines.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
lines: List of strings with potentially common indentation
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
List of strings with common indentation removed
|
|
108
|
+
|
|
109
|
+
Example:
|
|
110
|
+
>>> parser._dedent_lines(["First main", " - Sub 1a", " - Sub 1b"])
|
|
111
|
+
["First main", "- Sub 1a", "- Sub 1b"]
|
|
112
|
+
"""
|
|
113
|
+
# Find minimum indentation (excluding blank lines AND lines with no indentation)
|
|
114
|
+
indented_lines = [line for line in lines if line.strip() and line[0] in (' ', '\t')]
|
|
115
|
+
|
|
116
|
+
if not indented_lines:
|
|
117
|
+
# No indented lines - return as is
|
|
118
|
+
return lines
|
|
119
|
+
|
|
120
|
+
# Calculate minimum indentation level from indented lines only
|
|
121
|
+
min_indent = min(len(line) - len(line.lstrip()) for line in indented_lines)
|
|
122
|
+
|
|
123
|
+
# Remove that much indentation from all lines that have it
|
|
124
|
+
dedented = []
|
|
125
|
+
for line in lines:
|
|
126
|
+
indent_level = len(line) - len(line.lstrip())
|
|
127
|
+
if indent_level >= min_indent:
|
|
128
|
+
# Remove the minimum indentation
|
|
129
|
+
dedented.append(line[min_indent:])
|
|
130
|
+
else:
|
|
131
|
+
# Line has less indentation than minimum - keep as is
|
|
132
|
+
dedented.append(line)
|
|
133
|
+
|
|
134
|
+
return dedented
|
|
135
|
+
|
|
136
|
+
def _split_blocks(self, content: str) -> List[str]:
|
|
137
|
+
"""
|
|
138
|
+
Split content into blocks separated by blank lines or heading boundaries.
|
|
139
|
+
|
|
140
|
+
Headings are treated as block boundaries - they start new blocks even
|
|
141
|
+
without blank lines before/after them. This allows proper parsing of
|
|
142
|
+
markdown where headings are immediately followed by content.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
content: Markdown content
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
List of block strings
|
|
149
|
+
"""
|
|
150
|
+
# Special handling for code blocks, details blocks, and headings
|
|
151
|
+
blocks = []
|
|
152
|
+
in_code_block = False
|
|
153
|
+
in_details_block = False
|
|
154
|
+
in_list = False # Track if we're accumulating a list block
|
|
155
|
+
current_block = []
|
|
156
|
+
|
|
157
|
+
lines = content.split("\n")
|
|
158
|
+
i = 0
|
|
159
|
+
while i < len(lines):
|
|
160
|
+
line = lines[i]
|
|
161
|
+
|
|
162
|
+
# Check if we're starting/continuing a list
|
|
163
|
+
is_list_marker = re.match(r"^\d+\.\s+", line) or re.match(r"^[*\-+]\s+", line)
|
|
164
|
+
if is_list_marker:
|
|
165
|
+
# If we're not already in a list and have content, flush it first
|
|
166
|
+
if not in_list and current_block and not in_code_block and not in_details_block:
|
|
167
|
+
blocks.append("\n".join(current_block))
|
|
168
|
+
current_block = []
|
|
169
|
+
in_list = True
|
|
170
|
+
|
|
171
|
+
# Check for code block delimiter
|
|
172
|
+
is_code_fence = line.strip().startswith("```")
|
|
173
|
+
is_indented = line.startswith((" ", "\t"))
|
|
174
|
+
|
|
175
|
+
if is_code_fence:
|
|
176
|
+
# If it's an indented code fence and we're in a list, don't split
|
|
177
|
+
if is_indented and in_list:
|
|
178
|
+
current_block.append(line)
|
|
179
|
+
i += 1
|
|
180
|
+
continue
|
|
181
|
+
elif not in_code_block:
|
|
182
|
+
# Start of a top-level code block
|
|
183
|
+
# If we have a current block, add it first
|
|
184
|
+
if current_block:
|
|
185
|
+
blocks.append("\n".join(current_block))
|
|
186
|
+
current_block = []
|
|
187
|
+
in_list = False # Reset list tracking
|
|
188
|
+
|
|
189
|
+
# Start collecting the code block
|
|
190
|
+
in_code_block = True
|
|
191
|
+
current_block.append(line)
|
|
192
|
+
else:
|
|
193
|
+
# End of a code block
|
|
194
|
+
current_block.append(line)
|
|
195
|
+
blocks.append("\n".join(current_block))
|
|
196
|
+
current_block = []
|
|
197
|
+
in_code_block = False
|
|
198
|
+
# Check for details block start
|
|
199
|
+
elif line.strip().lower().startswith("<details"):
|
|
200
|
+
# Start of a details block
|
|
201
|
+
# If we have a current block, add it first
|
|
202
|
+
if current_block:
|
|
203
|
+
blocks.append("\n".join(current_block))
|
|
204
|
+
current_block = []
|
|
205
|
+
|
|
206
|
+
# Start collecting the details block
|
|
207
|
+
in_details_block = True
|
|
208
|
+
current_block.append(line)
|
|
209
|
+
# Check for details block end
|
|
210
|
+
elif line.strip().lower() == "</details>":
|
|
211
|
+
# End of a details block
|
|
212
|
+
current_block.append(line)
|
|
213
|
+
blocks.append("\n".join(current_block))
|
|
214
|
+
current_block = []
|
|
215
|
+
in_details_block = False
|
|
216
|
+
elif not in_code_block and not in_details_block and line.startswith("#") and " " in line:
|
|
217
|
+
# This is a heading line (outside code/details blocks)
|
|
218
|
+
# Flush the current block if it exists
|
|
219
|
+
if current_block:
|
|
220
|
+
blocks.append("\n".join(current_block))
|
|
221
|
+
current_block = []
|
|
222
|
+
in_list = False # Reset list tracking
|
|
223
|
+
|
|
224
|
+
# Add the heading as its own block
|
|
225
|
+
blocks.append(line)
|
|
226
|
+
else:
|
|
227
|
+
# Handle blank lines
|
|
228
|
+
if not in_code_block and not in_details_block and not line.strip():
|
|
229
|
+
if current_block:
|
|
230
|
+
# Check if the blank line should end the block
|
|
231
|
+
# Only end if we're not in a list
|
|
232
|
+
if in_list:
|
|
233
|
+
# Look ahead to see if the list continues
|
|
234
|
+
# Find the next non-blank line
|
|
235
|
+
next_line_idx = i + 1
|
|
236
|
+
next_line = None
|
|
237
|
+
while next_line_idx < len(lines):
|
|
238
|
+
if lines[next_line_idx].strip():
|
|
239
|
+
next_line = lines[next_line_idx]
|
|
240
|
+
break
|
|
241
|
+
next_line_idx += 1
|
|
242
|
+
|
|
243
|
+
# Check if next line continues the list
|
|
244
|
+
list_continues = False
|
|
245
|
+
if next_line:
|
|
246
|
+
# List continues if next line is a list marker or indented
|
|
247
|
+
is_list_marker = re.match(r"^\d+\.\s+", next_line) or re.match(r"^[*\-+]\s+", next_line)
|
|
248
|
+
is_indented = next_line.startswith((" ", "\t"))
|
|
249
|
+
list_continues = is_list_marker or is_indented
|
|
250
|
+
|
|
251
|
+
if list_continues:
|
|
252
|
+
# Keep accumulating - blank lines within lists are OK
|
|
253
|
+
current_block.append(line)
|
|
254
|
+
else:
|
|
255
|
+
# List has ended - flush the block
|
|
256
|
+
blocks.append("\n".join(current_block))
|
|
257
|
+
current_block = []
|
|
258
|
+
in_list = False
|
|
259
|
+
else:
|
|
260
|
+
blocks.append("\n".join(current_block))
|
|
261
|
+
current_block = []
|
|
262
|
+
# else: Skip leading blank lines
|
|
263
|
+
else:
|
|
264
|
+
current_block.append(line)
|
|
265
|
+
|
|
266
|
+
i += 1
|
|
267
|
+
|
|
268
|
+
# Add any remaining content
|
|
269
|
+
if current_block:
|
|
270
|
+
blocks.append("\n".join(current_block))
|
|
271
|
+
|
|
272
|
+
return blocks
|
|
273
|
+
|
|
274
|
+
def _register_block_parsers(self) -> None:
|
|
275
|
+
"""Register default block parsers with their regex patterns."""
|
|
276
|
+
# Heading - # Heading
|
|
277
|
+
self._block_parsers["heading"] = (
|
|
278
|
+
re.compile(r"^(#+)\s+(.+)$"),
|
|
279
|
+
lambda m, content: self._parse_heading(m, content),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# Mermaid diagram - needs to be before generic code block to take precedence
|
|
283
|
+
# Allow optional leading whitespace for indented mermaid diagrams in lists
|
|
284
|
+
self._block_parsers["mermaid"] = (
|
|
285
|
+
re.compile(r"^\s*```mermaid\n([\s\S]+?)\n\s*```$"),
|
|
286
|
+
lambda m, content: self._parse_mermaid(m, content),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
# Code block - ```language\ncode\n```
|
|
290
|
+
# Allow optional leading whitespace for indented code blocks in lists
|
|
291
|
+
self._block_parsers["code_block"] = (
|
|
292
|
+
re.compile(r"^\s*```(\w*)\n([\s\S]+?)\n\s*```$"),
|
|
293
|
+
lambda m, content: self._parse_code_block(m, content),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# Expand/details block - <details><summary>Title</summary>\nContent\n</details>
|
|
297
|
+
self._block_parsers["expand"] = (
|
|
298
|
+
re.compile(r"^<details>\s*\n<summary>(.*?)</summary>\s*\n([\s\S]*?)\n</details>$", re.IGNORECASE),
|
|
299
|
+
lambda m, content: self._parse_expand(m, content),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# Blockquote - > Quote
|
|
303
|
+
self._block_parsers["blockquote"] = (
|
|
304
|
+
re.compile(r"^>\s*(.+)(\n>\s*.*)*$"),
|
|
305
|
+
lambda m, content: self._parse_blockquote(m, content),
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# Task list - - [ ] Item or - [x] Item (must be before bullet_list)
|
|
309
|
+
self._block_parsers["task_list"] = (
|
|
310
|
+
re.compile(r"^([*\-+]\s+\[[x ]\]\s+.+)(\n[*\-+]\s+\[[x ]\]\s+.*)*$", re.IGNORECASE),
|
|
311
|
+
lambda m, content: self._parse_task_list(m, content),
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
# Bullet list - - Item or * Item (permissive to include indented content)
|
|
315
|
+
self._block_parsers["bullet_list"] = (
|
|
316
|
+
re.compile(r"^([*\-+]\s+.+)(\n.*)*$"),
|
|
317
|
+
lambda m, content: self._parse_bullet_list(m, content),
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
# Ordered list - 1. Item (permissive to include indented content)
|
|
321
|
+
self._block_parsers["ordered_list"] = (
|
|
322
|
+
re.compile(r"^(\d+\.\s+.+)(\n.*)*$"),
|
|
323
|
+
lambda m, content: self._parse_ordered_list(m, content),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# Horizontal rule - ---, ***, or ___
|
|
327
|
+
self._block_parsers["horizontal_rule"] = (
|
|
328
|
+
re.compile(r"^([-*_])\1{2,}\s*$"),
|
|
329
|
+
lambda m, content: self._parse_horizontal_rule(m, content),
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
# Table - | Header | Header | with separator line
|
|
333
|
+
self._block_parsers["table"] = (
|
|
334
|
+
re.compile(r"^\|(.+)\|\s*\n\|[-:| ]+\|\s*\n(\|.+\|\s*\n?)+$"),
|
|
335
|
+
lambda m, content: self._parse_table(m, content),
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
# Multi-column layout - ::: columns
|
|
339
|
+
self._block_parsers["layout_section"] = (
|
|
340
|
+
re.compile(r"^::: columns\s*\n([\s\S]+?)\n:::$"),
|
|
341
|
+
lambda m, content: self._parse_layout_section(m, content),
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# URL embed - standalone URLs (YouTube, Vimeo, etc.)
|
|
345
|
+
self._block_parsers["url_embed"] = (
|
|
346
|
+
re.compile(r"^(https?://(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/|vimeo\.com/)\S+)$"),
|
|
347
|
+
lambda m, content: self._parse_url_embed(m, content),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
# Block macros with content - {macro:params}...{macro}
|
|
351
|
+
# Info panel - {info}...{info} or {info:title=...}...{info}
|
|
352
|
+
self._block_parsers["info_macro"] = (
|
|
353
|
+
re.compile(r"^\{info(?::([^}]+))?\}\s*\n([\s\S]*?)\n\{info\}$"),
|
|
354
|
+
lambda m, content: self._parse_info_macro(m, content),
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# Warning panel - {warning}...{warning} or {warning:title=...}...{warning}
|
|
358
|
+
self._block_parsers["warning_macro"] = (
|
|
359
|
+
re.compile(r"^\{warning(?::([^}]+))?\}\s*\n([\s\S]*?)\n\{warning\}$"),
|
|
360
|
+
lambda m, content: self._parse_warning_macro(m, content),
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# Note panel - {note}...{note} or {note:title=...}...{note}
|
|
364
|
+
self._block_parsers["note_macro"] = (
|
|
365
|
+
re.compile(r"^\{note(?::([^}]+))?\}\s*\n([\s\S]*?)\n\{note\}$"),
|
|
366
|
+
lambda m, content: self._parse_note_macro(m, content),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
# Excerpt - {excerpt}...{excerpt} or {excerpt:hidden=...}...{excerpt}
|
|
370
|
+
self._block_parsers["excerpt_macro"] = (
|
|
371
|
+
re.compile(r"^\{excerpt(?::([^}]+))?\}\s*\n([\s\S]*?)\n\{excerpt\}$"),
|
|
372
|
+
lambda m, content: self._parse_excerpt_macro(m, content),
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
# Expand macro - {expand:title=...}...{expand}
|
|
376
|
+
self._block_parsers["expand_macro"] = (
|
|
377
|
+
re.compile(r"^\{expand(?::([^}]+))?\}\s*\n([\s\S]*?)\n\{expand\}$"),
|
|
378
|
+
lambda m, content: self._parse_expand_macro(m, content),
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
# Code block macro - {code:language=...}...{code}
|
|
382
|
+
self._block_parsers["code_macro"] = (
|
|
383
|
+
re.compile(r"^\{code(?::([^}]+))?\}\s*\n([\s\S]*?)\n\{code\}$"),
|
|
384
|
+
lambda m, content: self._parse_code_macro(m, content),
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
def _parse_block(self, block: str) -> Optional[MarkdownNode]:
|
|
388
|
+
"""
|
|
389
|
+
Parse a block of Markdown content.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
block: Block of Markdown content
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
Parsed node or None if not recognized
|
|
396
|
+
"""
|
|
397
|
+
# Try all registered block parsers
|
|
398
|
+
for name, (pattern, parser) in self._block_parsers.items():
|
|
399
|
+
match = pattern.match(block)
|
|
400
|
+
if match:
|
|
401
|
+
return parser(match, block)
|
|
402
|
+
|
|
403
|
+
# Default to paragraph for unrecognized blocks
|
|
404
|
+
paragraph = ParagraphNode()
|
|
405
|
+
paragraph.children.extend(self._parse_inline(block))
|
|
406
|
+
return paragraph
|
|
407
|
+
|
|
408
|
+
def _parse_heading(self, match: Match, content: str) -> HeadingNode:
|
|
409
|
+
"""
|
|
410
|
+
Parse a heading.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
match: Regex match
|
|
414
|
+
content: Original content
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
HeadingNode
|
|
418
|
+
"""
|
|
419
|
+
level = len(match.group(1))
|
|
420
|
+
text = match.group(2).strip()
|
|
421
|
+
|
|
422
|
+
heading = HeadingNode(level=min(level, 6))
|
|
423
|
+
heading.children.extend(self._parse_inline(text))
|
|
424
|
+
|
|
425
|
+
return heading
|
|
426
|
+
|
|
427
|
+
def _parse_code_block(self, match: Match, content: str) -> CodeBlockNode:
|
|
428
|
+
"""
|
|
429
|
+
Parse a code block.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
match: Regex match
|
|
433
|
+
content: Original content
|
|
434
|
+
|
|
435
|
+
Returns:
|
|
436
|
+
CodeBlockNode
|
|
437
|
+
"""
|
|
438
|
+
language = match.group(1) or None
|
|
439
|
+
code = match.group(2)
|
|
440
|
+
|
|
441
|
+
return CodeBlockNode(language=language, content=code)
|
|
442
|
+
|
|
443
|
+
def _parse_blockquote(self, match: Match, content: str) -> BlockquoteNode:
|
|
444
|
+
"""
|
|
445
|
+
Parse a blockquote.
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
match: Regex match
|
|
449
|
+
content: Original content
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
BlockquoteNode
|
|
453
|
+
"""
|
|
454
|
+
# Extract the blockquote content
|
|
455
|
+
lines = content.strip().split("\n")
|
|
456
|
+
quote_content = []
|
|
457
|
+
|
|
458
|
+
for line in lines:
|
|
459
|
+
line = re.sub(r"^>\s?", "", line)
|
|
460
|
+
quote_content.append(line)
|
|
461
|
+
|
|
462
|
+
combined = "\n".join(quote_content)
|
|
463
|
+
|
|
464
|
+
blockquote = BlockquoteNode()
|
|
465
|
+
paragraph = ParagraphNode()
|
|
466
|
+
paragraph.children.extend(self._parse_inline(combined))
|
|
467
|
+
blockquote.children.append(paragraph)
|
|
468
|
+
|
|
469
|
+
return blockquote
|
|
470
|
+
|
|
471
|
+
def _parse_expand(self, match: Match, content: str) -> ExpandNode:
|
|
472
|
+
"""
|
|
473
|
+
Parse an expand/collapsible section.
|
|
474
|
+
|
|
475
|
+
Args:
|
|
476
|
+
match: Regex match
|
|
477
|
+
content: Original content
|
|
478
|
+
|
|
479
|
+
Returns:
|
|
480
|
+
ExpandNode
|
|
481
|
+
"""
|
|
482
|
+
# Extract title from <summary> tag
|
|
483
|
+
title = match.group(1).strip() if match.group(1) else ""
|
|
484
|
+
|
|
485
|
+
# Extract content between </summary> and </details>
|
|
486
|
+
expand_content = match.group(2).strip() if match.group(2) else ""
|
|
487
|
+
|
|
488
|
+
expand = ExpandNode(title=title)
|
|
489
|
+
|
|
490
|
+
if expand_content:
|
|
491
|
+
# Recursively parse the content to get child nodes
|
|
492
|
+
# This allows expand sections to contain paragraphs, lists, headings, etc.
|
|
493
|
+
child_nodes = self.parse(expand_content)
|
|
494
|
+
expand.children.extend(child_nodes)
|
|
495
|
+
else:
|
|
496
|
+
# If no content, add an empty paragraph to satisfy ADF validation
|
|
497
|
+
expand.children.append(ParagraphNode())
|
|
498
|
+
|
|
499
|
+
return expand
|
|
500
|
+
|
|
501
|
+
def _parse_bullet_list(self, match: Match, content: str) -> BulletListNode:
|
|
502
|
+
"""
|
|
503
|
+
Parse a bullet list, supporting multi-line items with nested code blocks.
|
|
504
|
+
|
|
505
|
+
Args:
|
|
506
|
+
match: Regex match
|
|
507
|
+
content: Original content
|
|
508
|
+
|
|
509
|
+
Returns:
|
|
510
|
+
BulletListNode
|
|
511
|
+
"""
|
|
512
|
+
lines = content.strip().split("\n")
|
|
513
|
+
list_node = BulletListNode()
|
|
514
|
+
|
|
515
|
+
i = 0
|
|
516
|
+
while i < len(lines):
|
|
517
|
+
line = lines[i]
|
|
518
|
+
|
|
519
|
+
# Check if this line starts a list item
|
|
520
|
+
list_item_match = re.match(r"^([*\-+])\s+(.*)$", line)
|
|
521
|
+
if list_item_match:
|
|
522
|
+
list_item_match.group(1)
|
|
523
|
+
first_line_content = list_item_match.group(2)
|
|
524
|
+
|
|
525
|
+
# Collect all lines that belong to this item (indented continuation)
|
|
526
|
+
item_lines = [first_line_content]
|
|
527
|
+
i += 1
|
|
528
|
+
|
|
529
|
+
# Look ahead for indented lines (including code blocks)
|
|
530
|
+
while i < len(lines):
|
|
531
|
+
next_line = lines[i]
|
|
532
|
+
# If next line starts a new list item, stop
|
|
533
|
+
if re.match(r"^[*\-+]\s+", next_line):
|
|
534
|
+
break
|
|
535
|
+
# If next line is indented or blank (within item), include it
|
|
536
|
+
if next_line.startswith((" ", "\t")) or not next_line.strip():
|
|
537
|
+
item_lines.append(next_line)
|
|
538
|
+
i += 1
|
|
539
|
+
else:
|
|
540
|
+
# Non-indented, non-list-item line - stop
|
|
541
|
+
break
|
|
542
|
+
|
|
543
|
+
# Dedent the item lines before joining to allow nested lists to match
|
|
544
|
+
# List markers like " - Sub-item" need to become "- Sub-item" to match patterns
|
|
545
|
+
item_lines_dedented = self._dedent_lines(item_lines)
|
|
546
|
+
|
|
547
|
+
# Join the dedented content and recursively parse it
|
|
548
|
+
item_content = "\n".join(item_lines_dedented)
|
|
549
|
+
|
|
550
|
+
# Parse the item content as blocks (may contain code blocks, paragraphs, etc.)
|
|
551
|
+
item_node = ListItemNode()
|
|
552
|
+
item_blocks = self.parse(item_content)
|
|
553
|
+
item_node.children.extend(item_blocks)
|
|
554
|
+
|
|
555
|
+
list_node.children.append(item_node)
|
|
556
|
+
else:
|
|
557
|
+
# Skip non-list-item lines
|
|
558
|
+
i += 1
|
|
559
|
+
|
|
560
|
+
return list_node
|
|
561
|
+
|
|
562
|
+
def _parse_ordered_list(self, match: Match, content: str) -> OrderedListNode:
|
|
563
|
+
"""
|
|
564
|
+
Parse an ordered list, supporting multi-line items with nested code blocks.
|
|
565
|
+
|
|
566
|
+
Args:
|
|
567
|
+
match: Regex match
|
|
568
|
+
content: Original content
|
|
569
|
+
|
|
570
|
+
Returns:
|
|
571
|
+
OrderedListNode
|
|
572
|
+
"""
|
|
573
|
+
lines = content.strip().split("\n")
|
|
574
|
+
list_node = OrderedListNode()
|
|
575
|
+
|
|
576
|
+
i = 0
|
|
577
|
+
while i < len(lines):
|
|
578
|
+
line = lines[i]
|
|
579
|
+
|
|
580
|
+
# Check if this line starts a list item
|
|
581
|
+
list_item_match = re.match(r"^(\d+)\.\s+(.*)$", line)
|
|
582
|
+
if list_item_match:
|
|
583
|
+
list_item_match.group(1)
|
|
584
|
+
first_line_content = list_item_match.group(2)
|
|
585
|
+
|
|
586
|
+
# Collect all lines that belong to this item (indented continuation)
|
|
587
|
+
item_lines = [first_line_content]
|
|
588
|
+
i += 1
|
|
589
|
+
|
|
590
|
+
# Look ahead for indented lines (including code blocks)
|
|
591
|
+
while i < len(lines):
|
|
592
|
+
next_line = lines[i]
|
|
593
|
+
# If next line starts a new list item, stop
|
|
594
|
+
if re.match(r"^\d+\.\s+", next_line):
|
|
595
|
+
break
|
|
596
|
+
# If next line is indented or blank (within item), include it
|
|
597
|
+
if next_line.startswith((" ", "\t")) or not next_line.strip():
|
|
598
|
+
item_lines.append(next_line)
|
|
599
|
+
i += 1
|
|
600
|
+
else:
|
|
601
|
+
# Non-indented, non-list-item line - stop
|
|
602
|
+
break
|
|
603
|
+
|
|
604
|
+
# Dedent the item lines before joining to allow nested lists to match
|
|
605
|
+
# List markers like " 1. Sub-item" need to become "1. Sub-item" to match patterns
|
|
606
|
+
item_lines_dedented = self._dedent_lines(item_lines)
|
|
607
|
+
|
|
608
|
+
# Join the dedented content and recursively parse it
|
|
609
|
+
item_content = "\n".join(item_lines_dedented)
|
|
610
|
+
|
|
611
|
+
# Parse the item content as blocks (may contain code blocks, paragraphs, etc.)
|
|
612
|
+
item_node = ListItemNode()
|
|
613
|
+
item_blocks = self.parse(item_content)
|
|
614
|
+
item_node.children.extend(item_blocks)
|
|
615
|
+
|
|
616
|
+
list_node.children.append(item_node)
|
|
617
|
+
else:
|
|
618
|
+
# Skip non-list-item lines
|
|
619
|
+
i += 1
|
|
620
|
+
|
|
621
|
+
return list_node
|
|
622
|
+
|
|
623
|
+
def _parse_horizontal_rule(self, match: Match, content: str) -> HorizontalRuleNode:
|
|
624
|
+
"""
|
|
625
|
+
Parse a horizontal rule.
|
|
626
|
+
|
|
627
|
+
Args:
|
|
628
|
+
match: Regex match
|
|
629
|
+
content: Original content
|
|
630
|
+
|
|
631
|
+
Returns:
|
|
632
|
+
HorizontalRuleNode
|
|
633
|
+
"""
|
|
634
|
+
return HorizontalRuleNode()
|
|
635
|
+
|
|
636
|
+
def _parse_table(self, match: Match, content: str) -> TableNode:
|
|
637
|
+
"""
|
|
638
|
+
Parse a table.
|
|
639
|
+
|
|
640
|
+
Args:
|
|
641
|
+
match: Regex match
|
|
642
|
+
content: Original content
|
|
643
|
+
|
|
644
|
+
Returns:
|
|
645
|
+
TableNode
|
|
646
|
+
"""
|
|
647
|
+
lines = content.strip().split("\n")
|
|
648
|
+
table = TableNode()
|
|
649
|
+
|
|
650
|
+
# Extract headers from first line
|
|
651
|
+
header_line = lines[0]
|
|
652
|
+
headers = [cell.strip() for cell in header_line.split("|")[1:-1]]
|
|
653
|
+
table.headers = headers
|
|
654
|
+
|
|
655
|
+
# Skip the header and separator lines
|
|
656
|
+
for i in range(2, len(lines)):
|
|
657
|
+
row_line = lines[i]
|
|
658
|
+
if not row_line.strip():
|
|
659
|
+
continue
|
|
660
|
+
|
|
661
|
+
# Extract cells from row
|
|
662
|
+
cells = []
|
|
663
|
+
for cell_content in row_line.split("|")[1:-1]:
|
|
664
|
+
paragraph = ParagraphNode()
|
|
665
|
+
paragraph.children.extend(self._parse_inline(cell_content.strip()))
|
|
666
|
+
cells.append(paragraph)
|
|
667
|
+
|
|
668
|
+
table.rows.append(cells)
|
|
669
|
+
|
|
670
|
+
return table
|
|
671
|
+
|
|
672
|
+
def _parse_mermaid(self, match: Match, content: str) -> MermaidNode:
|
|
673
|
+
"""
|
|
674
|
+
Parse a Mermaid diagram.
|
|
675
|
+
|
|
676
|
+
Args:
|
|
677
|
+
match: Regex match
|
|
678
|
+
content: Original content
|
|
679
|
+
|
|
680
|
+
Returns:
|
|
681
|
+
MermaidNode
|
|
682
|
+
"""
|
|
683
|
+
code = match.group(1)
|
|
684
|
+
return MermaidNode(code=code)
|
|
685
|
+
|
|
686
|
+
def _parse_task_list(self, match: Match, content: str) -> TaskListNode:
|
|
687
|
+
"""
|
|
688
|
+
Parse a task list with checkboxes.
|
|
689
|
+
|
|
690
|
+
Args:
|
|
691
|
+
match: Regex match
|
|
692
|
+
content: Original content (e.g., "- [ ] Task 1\n- [x] Task 2")
|
|
693
|
+
|
|
694
|
+
Returns:
|
|
695
|
+
TaskListNode
|
|
696
|
+
"""
|
|
697
|
+
lines = content.strip().split("\n")
|
|
698
|
+
task_list = TaskListNode()
|
|
699
|
+
|
|
700
|
+
for line in lines:
|
|
701
|
+
# Extract checkbox state and content
|
|
702
|
+
# Pattern: - [ ] Item or - [x] Item
|
|
703
|
+
checkbox_match = re.match(r"^[*\-+]\s+\[([x ])\]\s+(.+)$", line, re.IGNORECASE)
|
|
704
|
+
if checkbox_match:
|
|
705
|
+
state = checkbox_match.group(1).lower()
|
|
706
|
+
item_content = checkbox_match.group(2)
|
|
707
|
+
|
|
708
|
+
checked = state == 'x'
|
|
709
|
+
|
|
710
|
+
# Create task item
|
|
711
|
+
task_item = TaskItemNode(checked=checked)
|
|
712
|
+
paragraph = ParagraphNode()
|
|
713
|
+
paragraph.children.extend(self._parse_inline(item_content))
|
|
714
|
+
task_item.children.append(paragraph)
|
|
715
|
+
|
|
716
|
+
task_list.children.append(task_item)
|
|
717
|
+
|
|
718
|
+
return task_list
|
|
719
|
+
|
|
720
|
+
def _parse_layout_section(self, match: Match, content: str) -> LayoutSectionNode:
|
|
721
|
+
"""
|
|
722
|
+
Parse a multi-column layout section.
|
|
723
|
+
|
|
724
|
+
Args:
|
|
725
|
+
match: Regex match
|
|
726
|
+
content: Original content
|
|
727
|
+
|
|
728
|
+
Returns:
|
|
729
|
+
LayoutSectionNode
|
|
730
|
+
"""
|
|
731
|
+
# Extract content between ::: columns and :::
|
|
732
|
+
layout_content = match.group(1).strip()
|
|
733
|
+
|
|
734
|
+
# Parse columns - format: ::: column width=50\nContent\n:::
|
|
735
|
+
column_pattern = re.compile(r"::: column(?:\s+width=(\d+))?\s*\n([\s\S]+?)(?=\n:::(?:\s+column|\s*$))", re.MULTILINE)
|
|
736
|
+
columns = column_pattern.findall(layout_content)
|
|
737
|
+
|
|
738
|
+
layout_section = LayoutSectionNode()
|
|
739
|
+
|
|
740
|
+
for width_str, column_content in columns:
|
|
741
|
+
# Parse width if provided
|
|
742
|
+
width = int(width_str) if width_str else None
|
|
743
|
+
|
|
744
|
+
# Create column node
|
|
745
|
+
column = LayoutColumnNode(width=width)
|
|
746
|
+
|
|
747
|
+
# Parse column content recursively
|
|
748
|
+
column_nodes = self.parse(column_content.strip())
|
|
749
|
+
column.children.extend(column_nodes)
|
|
750
|
+
|
|
751
|
+
layout_section.children.append(column)
|
|
752
|
+
|
|
753
|
+
# If no columns were parsed, add empty column
|
|
754
|
+
if not layout_section.children:
|
|
755
|
+
column = LayoutColumnNode()
|
|
756
|
+
column.children.append(ParagraphNode())
|
|
757
|
+
layout_section.children.append(column)
|
|
758
|
+
|
|
759
|
+
return layout_section
|
|
760
|
+
|
|
761
|
+
def _parse_url_embed(self, match: Match, content: str) -> URLEmbedNode:
|
|
762
|
+
"""
|
|
763
|
+
Parse a standalone URL for embedding.
|
|
764
|
+
|
|
765
|
+
Args:
|
|
766
|
+
match: Regex match
|
|
767
|
+
content: Original content (just the URL)
|
|
768
|
+
|
|
769
|
+
Returns:
|
|
770
|
+
URLEmbedNode
|
|
771
|
+
"""
|
|
772
|
+
url = match.group(1)
|
|
773
|
+
|
|
774
|
+
# Determine embed type based on URL
|
|
775
|
+
if 'youtube.com' in url or 'youtu.be' in url:
|
|
776
|
+
embed_type = 'video'
|
|
777
|
+
elif 'vimeo.com' in url:
|
|
778
|
+
embed_type = 'video'
|
|
779
|
+
else:
|
|
780
|
+
embed_type = 'card'
|
|
781
|
+
|
|
782
|
+
return URLEmbedNode(url=url, embed_type=embed_type, layout='center')
|
|
783
|
+
|
|
784
|
+
def _parse_info_macro(self, match: Match, content: str) -> InfoNode:
|
|
785
|
+
"""
|
|
786
|
+
Parse an info panel macro.
|
|
787
|
+
|
|
788
|
+
Args:
|
|
789
|
+
match: Regex match
|
|
790
|
+
content: Original content
|
|
791
|
+
|
|
792
|
+
Returns:
|
|
793
|
+
InfoNode
|
|
794
|
+
"""
|
|
795
|
+
params_str = match.group(1) if match.lastindex >= 1 and match.group(1) else None
|
|
796
|
+
panel_content = match.group(2).strip()
|
|
797
|
+
|
|
798
|
+
# Parse parameters
|
|
799
|
+
params = self._parse_macro_parameters(params_str) if params_str else {}
|
|
800
|
+
title = params.get("title")
|
|
801
|
+
icon = params.get("icon", "true").lower() == "true"
|
|
802
|
+
|
|
803
|
+
# Create info node and parse content
|
|
804
|
+
info_node = InfoNode(title=title, icon=icon)
|
|
805
|
+
# Parse content blocks
|
|
806
|
+
info_node.children.extend(self.parse(panel_content))
|
|
807
|
+
|
|
808
|
+
return info_node
|
|
809
|
+
|
|
810
|
+
def _parse_warning_macro(self, match: Match, content: str) -> WarningNode:
|
|
811
|
+
"""
|
|
812
|
+
Parse a warning panel macro.
|
|
813
|
+
|
|
814
|
+
Args:
|
|
815
|
+
match: Regex match
|
|
816
|
+
content: Original content
|
|
817
|
+
|
|
818
|
+
Returns:
|
|
819
|
+
WarningNode
|
|
820
|
+
"""
|
|
821
|
+
params_str = match.group(1) if match.lastindex >= 1 and match.group(1) else None
|
|
822
|
+
panel_content = match.group(2).strip()
|
|
823
|
+
|
|
824
|
+
# Parse parameters
|
|
825
|
+
params = self._parse_macro_parameters(params_str) if params_str else {}
|
|
826
|
+
title = params.get("title")
|
|
827
|
+
icon = params.get("icon", "true").lower() == "true"
|
|
828
|
+
|
|
829
|
+
# Create warning node and parse content
|
|
830
|
+
warning_node = WarningNode(title=title, icon=icon)
|
|
831
|
+
warning_node.children.extend(self.parse(panel_content))
|
|
832
|
+
|
|
833
|
+
return warning_node
|
|
834
|
+
|
|
835
|
+
def _parse_note_macro(self, match: Match, content: str) -> NoteNode:
|
|
836
|
+
"""
|
|
837
|
+
Parse a note panel macro.
|
|
838
|
+
|
|
839
|
+
Args:
|
|
840
|
+
match: Regex match
|
|
841
|
+
content: Original content
|
|
842
|
+
|
|
843
|
+
Returns:
|
|
844
|
+
NoteNode
|
|
845
|
+
"""
|
|
846
|
+
params_str = match.group(1) if match.lastindex >= 1 and match.group(1) else None
|
|
847
|
+
panel_content = match.group(2).strip()
|
|
848
|
+
|
|
849
|
+
# Parse parameters
|
|
850
|
+
params = self._parse_macro_parameters(params_str) if params_str else {}
|
|
851
|
+
title = params.get("title")
|
|
852
|
+
icon = params.get("icon", "true").lower() == "true"
|
|
853
|
+
|
|
854
|
+
# Create note node and parse content
|
|
855
|
+
note_node = NoteNode(title=title, icon=icon)
|
|
856
|
+
note_node.children.extend(self.parse(panel_content))
|
|
857
|
+
|
|
858
|
+
return note_node
|
|
859
|
+
|
|
860
|
+
def _parse_excerpt_macro(self, match: Match, content: str) -> ExcerptNode:
|
|
861
|
+
"""
|
|
862
|
+
Parse an excerpt macro.
|
|
863
|
+
|
|
864
|
+
Args:
|
|
865
|
+
match: Regex match
|
|
866
|
+
content: Original content
|
|
867
|
+
|
|
868
|
+
Returns:
|
|
869
|
+
ExcerptNode
|
|
870
|
+
"""
|
|
871
|
+
params_str = match.group(1) if match.lastindex >= 1 and match.group(1) else None
|
|
872
|
+
excerpt_content = match.group(2).strip()
|
|
873
|
+
|
|
874
|
+
# Parse parameters
|
|
875
|
+
params = self._parse_macro_parameters(params_str) if params_str else {}
|
|
876
|
+
hidden = params.get("hidden", "false").lower() == "true"
|
|
877
|
+
output_type = params.get("atlassian-macro-output-type", "BLOCK")
|
|
878
|
+
|
|
879
|
+
# Create excerpt node and parse content
|
|
880
|
+
excerpt_node = ExcerptNode(hidden=hidden, atlassian_macro_output_type=output_type)
|
|
881
|
+
excerpt_node.children.extend(self.parse(excerpt_content))
|
|
882
|
+
|
|
883
|
+
return excerpt_node
|
|
884
|
+
|
|
885
|
+
def _parse_expand_macro(self, match: Match, content: str) -> ExpandMacroNode:
|
|
886
|
+
"""
|
|
887
|
+
Parse an expand (collapsible) macro.
|
|
888
|
+
|
|
889
|
+
Args:
|
|
890
|
+
match: Regex match
|
|
891
|
+
content: Original content
|
|
892
|
+
|
|
893
|
+
Returns:
|
|
894
|
+
ExpandMacroNode
|
|
895
|
+
"""
|
|
896
|
+
params_str = match.group(1) if match.lastindex >= 1 and match.group(1) else None
|
|
897
|
+
expand_content = match.group(2).strip()
|
|
898
|
+
|
|
899
|
+
# Parse parameters
|
|
900
|
+
params = self._parse_macro_parameters(params_str) if params_str else {}
|
|
901
|
+
title = params.get("title", "")
|
|
902
|
+
|
|
903
|
+
# Create expand node and parse content
|
|
904
|
+
expand_node = ExpandMacroNode(title=title)
|
|
905
|
+
expand_node.children.extend(self.parse(expand_content))
|
|
906
|
+
|
|
907
|
+
return expand_node
|
|
908
|
+
|
|
909
|
+
def _parse_code_macro(self, match: Match, content: str) -> CodeBlockMacroNode:
|
|
910
|
+
"""
|
|
911
|
+
Parse a code block macro with enhanced features.
|
|
912
|
+
|
|
913
|
+
Args:
|
|
914
|
+
match: Regex match
|
|
915
|
+
content: Original content
|
|
916
|
+
|
|
917
|
+
Returns:
|
|
918
|
+
CodeBlockMacroNode
|
|
919
|
+
"""
|
|
920
|
+
params_str = match.group(1) if match.lastindex >= 1 and match.group(1) else None
|
|
921
|
+
code_content = match.group(2)
|
|
922
|
+
|
|
923
|
+
# Parse parameters
|
|
924
|
+
params = self._parse_macro_parameters(params_str) if params_str else {}
|
|
925
|
+
language = params.get("language")
|
|
926
|
+
title = params.get("title")
|
|
927
|
+
linenumbers = params.get("linenumbers", "false").lower() == "true"
|
|
928
|
+
theme = params.get("theme")
|
|
929
|
+
collapse = params.get("collapse", "false").lower() == "true"
|
|
930
|
+
|
|
931
|
+
# Create code block macro node with code content
|
|
932
|
+
code_node = CodeBlockMacroNode(
|
|
933
|
+
language=language,
|
|
934
|
+
title=title,
|
|
935
|
+
linenumbers=linenumbers,
|
|
936
|
+
theme=theme,
|
|
937
|
+
collapse=collapse
|
|
938
|
+
)
|
|
939
|
+
code_node.content = code_content
|
|
940
|
+
|
|
941
|
+
return code_node
|
|
942
|
+
|
|
943
|
+
def _parse_macro_parameters(self, params_str: str) -> Dict[str, str]:
|
|
944
|
+
"""
|
|
945
|
+
Parse macro parameters from parameter string.
|
|
946
|
+
|
|
947
|
+
Supports both comma-separated and pipe-separated parameters:
|
|
948
|
+
- maxLevel=3,minLevel=1
|
|
949
|
+
- colour=Green|title=Active
|
|
950
|
+
|
|
951
|
+
Args:
|
|
952
|
+
params_str: Parameter string (e.g., "maxLevel=3,minLevel=1")
|
|
953
|
+
|
|
954
|
+
Returns:
|
|
955
|
+
Dictionary of parameter key-value pairs
|
|
956
|
+
"""
|
|
957
|
+
params = {}
|
|
958
|
+
|
|
959
|
+
# Determine separator (pipe for status, comma for others)
|
|
960
|
+
if "|" in params_str:
|
|
961
|
+
separator = "|"
|
|
962
|
+
else:
|
|
963
|
+
separator = ","
|
|
964
|
+
|
|
965
|
+
# Split by separator and parse key=value pairs
|
|
966
|
+
for pair in params_str.split(separator):
|
|
967
|
+
pair = pair.strip()
|
|
968
|
+
if "=" in pair:
|
|
969
|
+
key, value = pair.split("=", 1)
|
|
970
|
+
params[key.strip()] = value.strip()
|
|
971
|
+
|
|
972
|
+
return params
|
|
973
|
+
|
|
974
|
+
def _parse_inline(self, text: str) -> List[MarkdownNode]:
|
|
975
|
+
"""
|
|
976
|
+
Parse inline elements in text.
|
|
977
|
+
|
|
978
|
+
Args:
|
|
979
|
+
text: Text to parse
|
|
980
|
+
|
|
981
|
+
Returns:
|
|
982
|
+
List of inline nodes
|
|
983
|
+
"""
|
|
984
|
+
# Use the InlineParser to parse inline elements
|
|
985
|
+
return self._inline_parser.parse(text)
|
|
986
|
+
|
|
987
|
+
def _find_link_indices(self, text: str) -> List[Tuple[int, int, str, str]]:
|
|
988
|
+
"""
|
|
989
|
+
Find Markdown link indices in text.
|
|
990
|
+
|
|
991
|
+
Args:
|
|
992
|
+
text: Text to search
|
|
993
|
+
|
|
994
|
+
Returns:
|
|
995
|
+
List of (start, end, text, url) tuples
|
|
996
|
+
"""
|
|
997
|
+
pattern = r"\[([^\]]+)\]\(([^)]+)\)"
|
|
998
|
+
result = []
|
|
999
|
+
|
|
1000
|
+
for match in re.finditer(pattern, text):
|
|
1001
|
+
start, end = match.span()
|
|
1002
|
+
link_text = match.group(1)
|
|
1003
|
+
link_url = match.group(2)
|
|
1004
|
+
result.append((start, end, link_text, link_url))
|
|
1005
|
+
|
|
1006
|
+
return result
|