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.
Files changed (65) hide show
  1. docspan/__init__.py +3 -0
  2. docspan/__main__.py +0 -0
  3. docspan/backends/__init__.py +19 -0
  4. docspan/backends/base.py +85 -0
  5. docspan/backends/confluence/__init__.py +0 -0
  6. docspan/backends/confluence/adf/__init__.py +14 -0
  7. docspan/backends/confluence/adf/comparator.py +427 -0
  8. docspan/backends/confluence/adf/converter.py +119 -0
  9. docspan/backends/confluence/adf/converters.py +1449 -0
  10. docspan/backends/confluence/adf/interfaces.py +191 -0
  11. docspan/backends/confluence/adf/nodes.py +2085 -0
  12. docspan/backends/confluence/adf/parser.py +400 -0
  13. docspan/backends/confluence/adf/validators.py +161 -0
  14. docspan/backends/confluence/adf/visitors.py +495 -0
  15. docspan/backends/confluence/backend.py +227 -0
  16. docspan/backends/confluence/client.py +44 -0
  17. docspan/backends/confluence/config/__init__.py +21 -0
  18. docspan/backends/confluence/config/loader.py +107 -0
  19. docspan/backends/confluence/config/models.py +167 -0
  20. docspan/backends/confluence/config/validation.py +297 -0
  21. docspan/backends/confluence/markdown/__init__.py +22 -0
  22. docspan/backends/confluence/markdown/ast.py +819 -0
  23. docspan/backends/confluence/markdown/extensions/__init__.py +5 -0
  24. docspan/backends/confluence/markdown/extensions/frontmatter.py +80 -0
  25. docspan/backends/confluence/markdown/extensions/mermaid.py +64 -0
  26. docspan/backends/confluence/markdown/extensions/wikilinks.py +179 -0
  27. docspan/backends/confluence/markdown/inline_parser.py +495 -0
  28. docspan/backends/confluence/markdown/parser.py +1006 -0
  29. docspan/backends/confluence/models/__init__.py +18 -0
  30. docspan/backends/confluence/models/markdown_file.py +402 -0
  31. docspan/backends/confluence/models/page.py +212 -0
  32. docspan/backends/confluence/models/path_utils.py +34 -0
  33. docspan/backends/confluence/models/results.py +28 -0
  34. docspan/backends/confluence/models/sync_status.py +382 -0
  35. docspan/backends/confluence/services/__init__.py +0 -0
  36. docspan/backends/confluence/services/confluence/__init__.py +40 -0
  37. docspan/backends/confluence/services/confluence/attachment_client.py +147 -0
  38. docspan/backends/confluence/services/confluence/base_client.py +420 -0
  39. docspan/backends/confluence/services/confluence/client.py +376 -0
  40. docspan/backends/confluence/services/confluence/comment_client.py +682 -0
  41. docspan/backends/confluence/services/confluence/crawler.py +587 -0
  42. docspan/backends/confluence/services/confluence/label_client.py +130 -0
  43. docspan/backends/confluence/services/confluence/page_client.py +1288 -0
  44. docspan/backends/confluence/services/confluence/space_client.py +179 -0
  45. docspan/backends/confluence/services/confluence/url_parser.py +106 -0
  46. docspan/backends/google_docs/__init__.py +0 -0
  47. docspan/backends/google_docs/auth.py +143 -0
  48. docspan/backends/google_docs/backend.py +140 -0
  49. docspan/backends/google_docs/client.py +665 -0
  50. docspan/backends/google_docs/converter.py +471 -0
  51. docspan/backends/google_docs/docs_request_builder.py +232 -0
  52. docspan/backends/google_docs/docs_structure_parser.py +120 -0
  53. docspan/backends/google_docs/markdown_to_paragraph_parser.py +145 -0
  54. docspan/cli/__init__.py +0 -0
  55. docspan/cli/main.py +408 -0
  56. docspan/config.py +62 -0
  57. docspan/core/__init__.py +49 -0
  58. docspan/core/merge.py +30 -0
  59. docspan/core/orchestrator.py +332 -0
  60. docspan/core/paths.py +8 -0
  61. docspan/core/state.py +53 -0
  62. docspan-0.1.0.dist-info/METADATA +273 -0
  63. docspan-0.1.0.dist-info/RECORD +65 -0
  64. docspan-0.1.0.dist-info/WHEEL +4 -0
  65. 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