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:
2026-04-27 11:20:21 +09:00
parent 4e692d0e9a
commit aac1082bb7
2 changed files with 128 additions and 0 deletions

View File

@@ -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",