feat(notion-writer): add column_list blocks via Pandoc fenced div

Pandoc-style ::: columns / ::: column / ::: blocks emit a Notion
column_list with column children. Each column's body recurses through
_parse_lines so any block type (lists, code, nested columns) works
inside. Single-column wrappers degrade to plain paragraphs because
Notion requires at least two columns.

Single-depth-counter design: depth=1 on enter, increments on `::: columns`
or `::: column`, decrements on `:::`. depth=0 closes the wrapper.

+3 tests, 29 passing total.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-27 11:26:02 +09:00
parent 0f8d7b2df1
commit 0421a65327
2 changed files with 155 additions and 0 deletions

View File

@@ -135,6 +135,81 @@ def _parse_lines(lines: List[str]) -> List[Dict[str, Any]]:
blocks.append(create_toggle_block(summary_text, children))
continue
# Columns (Pandoc fenced div ::: columns)
if line.strip() == '::: columns':
columns_lines: List[List[str]] = []
current_col: Optional[List[str]] = None
i += 1
depth = 1 # we are inside our own wrapper
while i < len(lines) and depth > 0:
stripped_col = lines[i].strip()
if stripped_col == '::: columns':
depth += 1
if current_col is not None:
current_col.append(lines[i])
i += 1
continue
if stripped_col == '::: column':
depth += 1
if depth == 2:
# Top-level column inside our wrapper
if current_col is not None:
columns_lines.append(current_col)
current_col = []
else:
# Column inside a nested wrapper — record verbatim
if current_col is not None:
current_col.append(lines[i])
i += 1
continue
if stripped_col == ':::':
depth -= 1
if depth == 0:
# Closing our wrapper
if current_col is not None:
columns_lines.append(current_col)
current_col = None
i += 1
break
if depth == 1:
# Closing a top-level column
if current_col is not None:
columns_lines.append(current_col)
current_col = None
else:
# Closing something nested — record verbatim
if current_col is not None:
current_col.append(lines[i])
i += 1
continue
# Regular content line — append to current column if open
if current_col is not None:
current_col.append(lines[i])
i += 1
if depth > 0:
# Unclosed wrapper at EOF — degrade to paragraphs
print("Warning: unclosed ::: columns at EOF; emitting as paragraphs",
file=sys.stderr)
if current_col is not None:
columns_lines.append(current_col)
for col in columns_lines:
for inner in col:
if inner.strip():
blocks.append(create_paragraph_block(inner))
continue
# Drop columns that are pure whitespace
columns_lines = [c for c in columns_lines if any(li.strip() for li in c)]
if len(columns_lines) < 2:
# Notion requires >= 2 columns; single column degrades to paragraphs
for col in columns_lines:
for inner in col:
if inner.strip():
blocks.append(create_paragraph_block(inner))
continue
column_blocks = [_parse_lines(col_lines) for col_lines in columns_lines]
blocks.append(create_column_list_block(column_blocks))
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
@@ -382,6 +457,20 @@ def create_toggle_block(summary_text: str, children_blocks: List[Dict[str, Any]]
}
def create_column_list_block(columns: List[List[Dict[str, Any]]]) -> Dict[str, Any]:
"""Create a column_list block; each item in `columns` is the children-list for one column."""
return {
"object": "block",
"type": "column_list",
"column_list": {
"children": [
{"object": "block", "type": "column", "column": {"children": col_blocks}}
for col_blocks in columns
],
},
}
def create_code_block(code: str, language: str) -> Dict[str, Any]:
return {
"object": "block",