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:
@@ -135,6 +135,81 @@ def _parse_lines(lines: List[str]) -> List[Dict[str, Any]]:
|
|||||||
blocks.append(create_toggle_block(summary_text, children))
|
blocks.append(create_toggle_block(summary_text, children))
|
||||||
continue
|
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]):
|
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
|
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]:
|
def create_code_block(code: str, language: str) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"object": "block",
|
"object": "block",
|
||||||
|
|||||||
@@ -280,6 +280,69 @@ def test_toggle_unclosed_falls_through():
|
|||||||
_assert("body line" in joined, "body preserved as paragraph")
|
_assert("body line" in joined, "body preserved as paragraph")
|
||||||
|
|
||||||
|
|
||||||
|
def test_columns_two():
|
||||||
|
md = """::: columns
|
||||||
|
::: column
|
||||||
|
left content
|
||||||
|
:::
|
||||||
|
::: column
|
||||||
|
right content
|
||||||
|
:::
|
||||||
|
:::"""
|
||||||
|
blocks = markdown_to_notion_blocks(md)
|
||||||
|
_assert(len(blocks) == 1, "one block emitted")
|
||||||
|
_assert(blocks[0]["type"] == "column_list", "block type is column_list")
|
||||||
|
cols = blocks[0]["column_list"]["children"]
|
||||||
|
_assert(len(cols) == 2, "two columns")
|
||||||
|
_assert(cols[0]["type"] == "column", "child 0 is column")
|
||||||
|
_assert(cols[1]["type"] == "column", "child 1 is column")
|
||||||
|
left_para = cols[0]["column"]["children"][0]["paragraph"]["rich_text"][0]["text"]["content"]
|
||||||
|
right_para = cols[1]["column"]["children"][0]["paragraph"]["rich_text"][0]["text"]["content"]
|
||||||
|
_assert(left_para == "left content", "left column content preserved")
|
||||||
|
_assert(right_para == "right content", "right column content preserved")
|
||||||
|
|
||||||
|
|
||||||
|
def test_columns_with_blocks():
|
||||||
|
"""Columns can hold any block type via per-column _parse_lines recursion."""
|
||||||
|
md = """::: columns
|
||||||
|
::: column
|
||||||
|
- bullet a
|
||||||
|
- bullet b
|
||||||
|
:::
|
||||||
|
::: column
|
||||||
|
```python
|
||||||
|
print(1)
|
||||||
|
```
|
||||||
|
:::
|
||||||
|
:::"""
|
||||||
|
blocks = markdown_to_notion_blocks(md)
|
||||||
|
_assert(blocks[0]["type"] == "column_list", "outer is column_list")
|
||||||
|
cols = blocks[0]["column_list"]["children"]
|
||||||
|
_assert(len(cols) == 2, "two columns")
|
||||||
|
left_types = [c["type"] for c in cols[0]["column"]["children"]]
|
||||||
|
right_types = [c["type"] for c in cols[1]["column"]["children"]]
|
||||||
|
_assert(left_types.count("bulleted_list_item") == 2, "left column has 2 bullets")
|
||||||
|
_assert("code" in right_types, "right column has code block")
|
||||||
|
|
||||||
|
|
||||||
|
def test_columns_single_degrades():
|
||||||
|
"""Single-column ::: columns block degrades to paragraphs (Notion requires >= 2)."""
|
||||||
|
md = """::: columns
|
||||||
|
::: column
|
||||||
|
only one column here
|
||||||
|
:::
|
||||||
|
:::"""
|
||||||
|
blocks = markdown_to_notion_blocks(md)
|
||||||
|
types = [b["type"] for b in blocks]
|
||||||
|
_assert("column_list" not in types, "no column_list emitted for single column")
|
||||||
|
paragraph_texts = [
|
||||||
|
b["paragraph"]["rich_text"][0]["text"]["content"]
|
||||||
|
for b in blocks if b["type"] == "paragraph"
|
||||||
|
]
|
||||||
|
joined = " ".join(paragraph_texts)
|
||||||
|
_assert("only one column here" in joined, "content preserved as paragraph")
|
||||||
|
|
||||||
|
|
||||||
def test_rich_text_anchor_link_becomes_bold():
|
def test_rich_text_anchor_link_becomes_bold():
|
||||||
"""TOC-style fragment links must not be sent as Notion link annotations."""
|
"""TOC-style fragment links must not be sent as Notion link annotations."""
|
||||||
spans = parse_rich_text("see [Section A](#section-a) below")
|
spans = parse_rich_text("see [Section A](#section-a) below")
|
||||||
@@ -339,6 +402,9 @@ def run_all():
|
|||||||
test_toggle_nested_blocks,
|
test_toggle_nested_blocks,
|
||||||
test_toggle_nested_toggle,
|
test_toggle_nested_toggle,
|
||||||
test_toggle_unclosed_falls_through,
|
test_toggle_unclosed_falls_through,
|
||||||
|
test_columns_two,
|
||||||
|
test_columns_with_blocks,
|
||||||
|
test_columns_single_degrades,
|
||||||
test_rich_text_anchor_link_becomes_bold,
|
test_rich_text_anchor_link_becomes_bold,
|
||||||
test_rich_text_relative_link_becomes_plain,
|
test_rich_text_relative_link_becomes_plain,
|
||||||
test_rich_text_absolute_link_preserved,
|
test_rich_text_absolute_link_preserved,
|
||||||
|
|||||||
Reference in New Issue
Block a user