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