refactor(notion-writer): make markdown_to_notion_blocks reentrant

Split the entry function into a public reentrant entry that accepts
either string or List[str], and a private _parse_lines engine that
container detectors will recurse into. No behavior change for existing
callers; all 16 parser tests still pass.

Prep for Phase 3c: callout/toggle/columns/page-mention block coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-27 11:07:43 +09:00
parent e2ae8aad94
commit c318df8551

View File

@@ -10,7 +10,7 @@ import re
import json
import argparse
from pathlib import Path
from typing import Optional, List, Dict, Any
from typing import Optional, List, Dict, Any, Union
from dotenv import load_dotenv
from notion_client import Client
@@ -72,32 +72,36 @@ def _split_table_row(line: str) -> List[str]:
return [cell.strip() for cell in stripped.split('|')]
def markdown_to_notion_blocks(markdown_text: str) -> List[Dict[str, Any]]:
"""Convert markdown text to Notion block objects."""
blocks = []
lines = markdown_text.split('\n')
i = 0
def markdown_to_notion_blocks(content: Union[str, List[str]]) -> List[Dict[str, Any]]:
"""Convert markdown text (or pre-split lines) to Notion block objects.
Reentrant: container detectors recursively call _parse_lines on inner content.
"""
if isinstance(content, str):
lines = content.split('\n')
else:
lines = list(content)
return _parse_lines(lines)
def _parse_lines(lines: List[str]) -> List[Dict[str, Any]]:
"""Walk lines and emit Notion blocks. Called recursively by container detectors."""
blocks = []
i = 0
while i < len(lines):
line = lines[i]
# Skip empty lines
if not line.strip():
i += 1
continue
# Table (header row + separator + body rows)
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 # skip separator
i += 2
body_rows: List[List[str]] = []
while i < len(lines) and _is_table_row(lines[i]):
body_rows.append(_split_table_row(lines[i]))
i += 1
blocks.append(create_table_block(header_cells, body_rows))
continue
# Headers
if line.startswith('######'):
blocks.append(create_heading_block(line[6:].strip(), 3))
elif line.startswith('#####'):
@@ -110,8 +114,6 @@ def markdown_to_notion_blocks(markdown_text: str) -> List[Dict[str, Any]]:
blocks.append(create_heading_block(line[2:].strip(), 2))
elif line.startswith('#'):
blocks.append(create_heading_block(line[1:].strip(), 1))
# Code blocks
elif line.startswith('```'):
language = line[3:].strip() or 'plain text'
code_lines = []
@@ -120,40 +122,26 @@ def markdown_to_notion_blocks(markdown_text: str) -> List[Dict[str, Any]]:
code_lines.append(lines[i])
i += 1
blocks.append(create_code_block('\n'.join(code_lines), language))
# Checkbox / Todo (must come before generic bullet match)
elif line.strip().startswith('- [ ]'):
text = line.strip()[5:].strip()
blocks.append(create_todo_block(text, False))
elif line.strip().startswith('- [x]') or line.strip().startswith('- [X]'):
text = line.strip()[5:].strip()
blocks.append(create_todo_block(text, True))
# Bullet list
elif line.strip().startswith('- ') or line.strip().startswith('* '):
text = line.strip()[2:]
blocks.append(create_bulleted_list_block(text))
# Numbered list
elif re.match(r'^\d+\.\s', line.strip()):
text = re.sub(r'^\d+\.\s', '', line.strip())
blocks.append(create_numbered_list_block(text))
# Blockquote
elif line.startswith('>'):
text = line[1:].strip()
blocks.append(create_quote_block(text))
# Horizontal rule
elif line.strip() in ['---', '***', '___']:
blocks.append(create_divider_block())
# Regular paragraph
else:
blocks.append(create_paragraph_block(line))
i += 1
return blocks