feat(notion-writer): add toggle blocks via HTML5 <details>
Multi-line <details>...</details> with optional <summary> emits a Notion toggle block. Body recurses through _parse_lines so any block type (lists, code, nested toggles) is supported inside. Depth tracking lets <details> nest inside <details>. Unclosed <details> at EOF degrades to plain paragraphs with a stderr warning instead of crashing. +4 tests, 26 passing total. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -93,6 +93,49 @@ def _parse_lines(lines: List[str]) -> List[Dict[str, Any]]:
|
||||
if not line.strip():
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Toggle (HTML5 <details>) — multi-line form, depth-tracked for nesting
|
||||
if line.strip() == '<details>':
|
||||
i += 1
|
||||
# Optional <summary>...</summary> on next non-blank line
|
||||
summary_text = ''
|
||||
while i < len(lines) and not lines[i].strip():
|
||||
i += 1
|
||||
if i < len(lines):
|
||||
summary_match = re.match(r'^\s*<summary>(.*)</summary>\s*$', lines[i])
|
||||
if summary_match:
|
||||
summary_text = summary_match.group(1)
|
||||
i += 1
|
||||
# Collect body lines until matching </details>, depth-tracked
|
||||
inner_lines: List[str] = []
|
||||
depth = 1
|
||||
while i < len(lines) and depth > 0:
|
||||
stripped_inner = lines[i].strip()
|
||||
if stripped_inner == '<details>':
|
||||
depth += 1
|
||||
inner_lines.append(lines[i])
|
||||
elif stripped_inner == '</details>':
|
||||
depth -= 1
|
||||
if depth > 0:
|
||||
inner_lines.append(lines[i])
|
||||
else:
|
||||
inner_lines.append(lines[i])
|
||||
i += 1
|
||||
if depth > 0:
|
||||
# Unclosed <details> at EOF — degrade to paragraphs
|
||||
import sys as _sys
|
||||
print("Warning: unclosed <details> at EOF; emitting body as paragraphs",
|
||||
file=_sys.stderr)
|
||||
if summary_text:
|
||||
blocks.append(create_paragraph_block(summary_text))
|
||||
for inner in inner_lines:
|
||||
if inner.strip():
|
||||
blocks.append(create_paragraph_block(inner))
|
||||
continue
|
||||
children = _parse_lines(inner_lines)
|
||||
blocks.append(create_toggle_block(summary_text, children))
|
||||
continue
|
||||
|
||||
if _is_table_row(line) and i + 1 < len(lines) and TABLE_SEPARATOR_RE.match(lines[i + 1]):
|
||||
header_cells = _split_table_row(line)
|
||||
i += 2
|
||||
@@ -328,6 +371,18 @@ def create_callout_block(text: str, alert_type: str) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def create_toggle_block(summary_text: str, children_blocks: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Create a toggle block with summary rich-text and child blocks."""
|
||||
return {
|
||||
"object": "block",
|
||||
"type": "toggle",
|
||||
"toggle": {
|
||||
"rich_text": parse_rich_text(summary_text),
|
||||
"children": children_blocks,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def create_code_block(code: str, language: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"object": "block",
|
||||
|
||||
@@ -211,6 +211,75 @@ def test_callout_unknown_falls_through():
|
||||
_assert("[!BOGUS]" in quote_text, "[!BOGUS] marker preserved in quote text")
|
||||
|
||||
|
||||
def test_toggle_basic():
|
||||
md = "<details>\n<summary>Click to expand</summary>\n\nInner paragraph.\n</details>"
|
||||
blocks = markdown_to_notion_blocks(md)
|
||||
_assert(len(blocks) == 1, "one block emitted")
|
||||
_assert(blocks[0]["type"] == "toggle", "block type is toggle")
|
||||
summary_text = blocks[0]["toggle"]["rich_text"][0]["text"]["content"]
|
||||
_assert(summary_text == "Click to expand", "summary text preserved")
|
||||
children = blocks[0]["toggle"]["children"]
|
||||
_assert(len(children) == 1, "one child block")
|
||||
_assert(children[0]["type"] == "paragraph", "child is paragraph")
|
||||
|
||||
|
||||
def test_toggle_nested_blocks():
|
||||
"""Toggle body can hold any block type via _parse_lines recursion."""
|
||||
md = """<details>
|
||||
<summary>Debug log</summary>
|
||||
|
||||
- step one
|
||||
- step two
|
||||
|
||||
```python
|
||||
print("hello")
|
||||
```
|
||||
</details>"""
|
||||
blocks = markdown_to_notion_blocks(md)
|
||||
_assert(blocks[0]["type"] == "toggle", "block is toggle")
|
||||
children = blocks[0]["toggle"]["children"]
|
||||
types = [c["type"] for c in children]
|
||||
_assert("bulleted_list_item" in types, "list child preserved")
|
||||
_assert("code" in types, "code child preserved")
|
||||
code_block = next(c for c in children if c["type"] == "code")
|
||||
_assert(code_block["code"]["language"] == "python", "code language preserved")
|
||||
|
||||
|
||||
def test_toggle_nested_toggle():
|
||||
"""details_depth tracking allows <details> inside <details>."""
|
||||
md = """<details>
|
||||
<summary>outer</summary>
|
||||
|
||||
<details>
|
||||
<summary>inner</summary>
|
||||
|
||||
inner content
|
||||
</details>
|
||||
</details>"""
|
||||
blocks = markdown_to_notion_blocks(md)
|
||||
_assert(blocks[0]["type"] == "toggle", "outer block is toggle")
|
||||
outer_children = blocks[0]["toggle"]["children"]
|
||||
_assert(len(outer_children) == 1, "outer has exactly one child")
|
||||
_assert(outer_children[0]["type"] == "toggle", "outer child is also toggle")
|
||||
inner_summary = outer_children[0]["toggle"]["rich_text"][0]["text"]["content"]
|
||||
_assert(inner_summary == "inner", "inner summary preserved")
|
||||
|
||||
|
||||
def test_toggle_unclosed_falls_through():
|
||||
"""Missing </details> at EOF degrades to paragraphs, no crash."""
|
||||
md = "<details>\n<summary>oops</summary>\n\nbody line"
|
||||
blocks = markdown_to_notion_blocks(md)
|
||||
types = [b["type"] for b in blocks]
|
||||
_assert("toggle" not in types, "no toggle emitted on unclosed <details>")
|
||||
paragraph_texts = [
|
||||
b["paragraph"]["rich_text"][0]["text"]["content"]
|
||||
for b in blocks if b["type"] == "paragraph"
|
||||
]
|
||||
joined = " ".join(paragraph_texts)
|
||||
_assert("oops" in joined, "summary preserved as paragraph")
|
||||
_assert("body line" in joined, "body preserved as paragraph")
|
||||
|
||||
|
||||
def test_rich_text_anchor_link_becomes_bold():
|
||||
"""TOC-style fragment links must not be sent as Notion link annotations."""
|
||||
spans = parse_rich_text("see [Section A](#section-a) below")
|
||||
@@ -266,6 +335,10 @@ def run_all():
|
||||
test_callout_warning,
|
||||
test_callout_caution,
|
||||
test_callout_unknown_falls_through,
|
||||
test_toggle_basic,
|
||||
test_toggle_nested_blocks,
|
||||
test_toggle_nested_toggle,
|
||||
test_toggle_unclosed_falls_through,
|
||||
test_rich_text_anchor_link_becomes_bold,
|
||||
test_rich_text_relative_link_becomes_plain,
|
||||
test_rich_text_absolute_link_preserved,
|
||||
|
||||
Reference in New Issue
Block a user