From aac1082bb739ed563ffba4f00ed0e2c12b014430 Mon Sep 17 00:00:00 2001 From: Andrew Yim Date: Mon, 27 Apr 2026 11:20:21 +0900 Subject: [PATCH] feat(notion-writer): add toggle blocks via HTML5
Multi-line
...
with optional emits a Notion toggle block. Body recurses through _parse_lines so any block type (lists, code, nested toggles) is supported inside. Depth tracking lets
nest inside
. Unclosed
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) --- .../code/scripts/notion_writer.py | 55 ++++++++++++++ .../code/scripts/test_parser.py | 73 +++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/custom-skills/32-notion-writer/code/scripts/notion_writer.py b/custom-skills/32-notion-writer/code/scripts/notion_writer.py index 3c91093..1b3224c 100644 --- a/custom-skills/32-notion-writer/code/scripts/notion_writer.py +++ b/custom-skills/32-notion-writer/code/scripts/notion_writer.py @@ -93,6 +93,49 @@ def _parse_lines(lines: List[str]) -> List[Dict[str, Any]]: if not line.strip(): i += 1 continue + + # Toggle (HTML5
) — multi-line form, depth-tracked for nesting + if line.strip() == '
': + i += 1 + # Optional ... 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*(.*)\s*$', lines[i]) + if summary_match: + summary_text = summary_match.group(1) + i += 1 + # Collect body lines until matching
, depth-tracked + inner_lines: List[str] = [] + depth = 1 + while i < len(lines) and depth > 0: + stripped_inner = lines[i].strip() + if stripped_inner == '
': + depth += 1 + inner_lines.append(lines[i]) + elif stripped_inner == '
': + depth -= 1 + if depth > 0: + inner_lines.append(lines[i]) + else: + inner_lines.append(lines[i]) + i += 1 + if depth > 0: + # Unclosed
at EOF — degrade to paragraphs + import sys as _sys + print("Warning: unclosed
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", diff --git a/custom-skills/32-notion-writer/code/scripts/test_parser.py b/custom-skills/32-notion-writer/code/scripts/test_parser.py index d0dca63..1a9a7a2 100644 --- a/custom-skills/32-notion-writer/code/scripts/test_parser.py +++ b/custom-skills/32-notion-writer/code/scripts/test_parser.py @@ -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 = "
\nClick to expand\n\nInner paragraph.\n
" + 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 = """
+Debug log + +- step one +- step two + +```python +print("hello") +``` +
""" + 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
inside
.""" + md = """
+outer + +
+inner + +inner content +
+
""" + 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
at EOF degrades to paragraphs, no crash.""" + md = "
\noops\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
") + 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,