Files
our-claude-skills/docs/superpowers/plans/2026-04-27-notion-writer-extended-block-coverage.md
Andrew Yim e2ae8aad94 docs(notion): add Phase 3c implementation plan
Six bite-sized TDD tasks covering reentrant parser refactor, callouts,
toggles, columns, page mentions, and docs. Each task ends with a
working commit; total 32 passing tests at completion.

Plan: docs/superpowers/plans/2026-04-27-notion-writer-extended-block-coverage.md
Spec: docs/superpowers/specs/2026-04-27-notion-writer-extended-block-coverage-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 11:03:39 +09:00

46 KiB
Raw Permalink Blame History

Notion Writer — Extended Block Coverage Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Extend notion_writer.py's markdown→Notion block parser to handle GitHub-alert callouts, HTML5 <details> toggles, Pandoc ::: columns fenced divs, and inline @[Title](id-or-url) page mentions.

Architecture: Reentrant flat parser. The existing markdown_to_notion_blocks becomes the public entry that dispatches to a private _parse_lines(lines) engine. Container detectors (toggle, columns) extract their inner lines and recurse through _parse_lines to produce nested children. Callouts emit a single rich-text run (no recursion). Page mentions extend INLINE_PATTERNS. Two depth ints track nested same-kind containers.

Tech Stack: Python 3.11+, regex line scanner, notion-client v3 SDK (no API calls during parse). Tests run via python test_parser.py (plain function-style asserts, no pytest).

Spec: docs/superpowers/specs/2026-04-27-notion-writer-extended-block-coverage-design.md


File Structure

File Purpose Change
custom-skills/32-notion-writer/code/scripts/notion_writer.py Main parser + Notion API client Refactor entry to reentrant; add 3 block factories; add 3 detector branches; add 1 inline pattern + emit branch; add ALERT_TYPES/ALERT_RE constants
custom-skills/32-notion-writer/code/scripts/test_parser.py Parser test suite (currently 16 tests) +16 tests, total 32
custom-skills/32-notion-writer/code/CLAUDE.md Skill documentation Update Markdown Support tables + version footer 1.1.0 → 1.2.0

No new files. No new dependencies.


Task 1: Make parser reentrant (no behavior change)

Files:

  • Modify: custom-skills/32-notion-writer/code/scripts/notion_writer.py:75-157

The current markdown_to_notion_blocks(markdown_text: str) is the only entry. We split it into a public reentrant entry + a private _parse_lines(lines) engine. All existing tests must continue to pass with no changes.

  • Step 1: Run the existing test suite to establish baseline
cd /Users/ourdigital/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts
python3 test_parser.py

Expected: ✅ All 16 tests passed

  • Step 2: Widen import to include Union

In notion_writer.py at line 12, change:

from typing import Optional, List, Dict, Any

to:

from typing import Optional, List, Dict, Any, Union
  • Step 3: Replace the entry function with reentrant form

Replace the entire body of markdown_to_notion_blocks (line 75 through line 157) with the following:

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
            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('#####'):
            blocks.append(create_heading_block(line[5:].strip(), 3))
        elif line.startswith('####'):
            blocks.append(create_heading_block(line[4:].strip(), 3))
        elif line.startswith('###'):
            blocks.append(create_heading_block(line[3:].strip(), 3))
        elif line.startswith('##'):
            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 = []
            i += 1
            while i < len(lines) and not lines[i].startswith('```'):
                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
  • Step 4: Run existing test suite to verify no regression
python3 test_parser.py

Expected: ✅ All 16 tests passed

  • Step 5: Verify the reentrant signature works (sanity check)
python3 -c "
from notion_writer import markdown_to_notion_blocks
str_result = markdown_to_notion_blocks('# Hello')
list_result = markdown_to_notion_blocks(['# Hello'])
assert str_result == list_result, 'str and list inputs must produce identical output'
print('Reentrant signature works:', len(str_result), 'block(s)')
"

Expected: Reentrant signature works: 1 block(s)

  • Step 6: Commit
cd /Users/ourdigital/Project/our-claude-skills
git add custom-skills/32-notion-writer/code/scripts/notion_writer.py
git commit -m "$(cat <<'EOF'
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>
EOF
)"

Task 2: Add GitHub-alert callout blocks

Files:

  • Modify: custom-skills/32-notion-writer/code/scripts/notion_writer.py (add constants, add factory, add detector branch)
  • Modify: custom-skills/32-notion-writer/code/scripts/test_parser.py (add 6 tests)

GitHub-style alerts (> [!NOTE], > [!TIP], etc.) emit a Notion callout block with an emoji icon and a colored background. Body is collected from contiguous > lines below the alert marker. Unknown alert types fall through to the existing quote handler.

  • Step 1: Write the first failing test (test_callout_note)

Open test_parser.py and add this test function before the run_all() function:

def test_callout_note():
    md = "> [!NOTE]\n> This is a note.\n> Spans multiple lines."
    blocks = markdown_to_notion_blocks(md)
    _assert(len(blocks) == 1, "exactly one block emitted")
    _assert(blocks[0]["type"] == "callout", "block type is callout")
    callout = blocks[0]["callout"]
    _assert(callout["icon"] == {"type": "emoji", "emoji": ""}, "NOTE icon is ")
    _assert(callout["color"] == "blue_background", "NOTE color is blue_background")
    body_text = "".join(s["text"]["content"] for s in callout["rich_text"])
    _assert("This is a note." in body_text, "body line 1 preserved")
    _assert("Spans multiple lines." in body_text, "body line 2 preserved")

Add test_callout_note to the tests list in run_all() (insert after test_blocks_table):

    tests = [
        # ... existing tests ...
        test_blocks_table,
        test_callout_note,
        test_rich_text_anchor_link_becomes_bold,
        # ... rest unchanged ...
    ]
  • Step 2: Run the new test to verify it fails
cd /Users/ourdigital/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts
python3 test_parser.py 2>&1 | grep -A 2 "test_callout_note"

Expected: FAIL — block type is "quote", not "callout".

  • Step 3: Add ALERT_TYPES and ALERT_RE constants

In notion_writer.py, add these constants immediately after the INLINE_PATTERNS block (around line 168, before _ABSOLUTE_URL_RE):

ALERT_TYPES = {
    'NOTE':      ('', 'blue_background'),
    'TIP':       ('💡', 'green_background'),
    'IMPORTANT': ('☝️', 'purple_background'),
    'WARNING':   ('⚠️', 'yellow_background'),
    'CAUTION':   ('🚨', 'red_background'),
}
ALERT_RE = re.compile(r'^\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*$')
  • Step 4: Add create_callout_block factory

In notion_writer.py, add this function after create_quote_block (search for def create_quote_block and add immediately after its closing bracket):

def create_callout_block(text: str, alert_type: str) -> Dict[str, Any]:
    """Create a callout block with icon + color from a GitHub alert type."""
    icon, color = ALERT_TYPES[alert_type]
    return {
        "type": "callout",
        "callout": {
            "rich_text": parse_rich_text(text),
            "icon": {"type": "emoji", "emoji": icon},
            "color": color,
        },
    }
  • Step 5: Add the callout detector branch in _parse_lines

In notion_writer.py, locate the blockquote handler in _parse_lines:

        # Blockquote
        elif line.startswith('>'):
            text = line[1:].strip()
            blocks.append(create_quote_block(text))

Replace it with:

        # Blockquote — GitHub-alert callout takes priority over generic quote
        elif line.startswith('>'):
            first_body = line[1:].strip()
            alert_match = ALERT_RE.match(first_body)
            if alert_match:
                alert_type = alert_match.group(1)
                body_lines: List[str] = []
                i += 1
                while i < len(lines) and lines[i].lstrip().startswith('>'):
                    body_lines.append(lines[i].lstrip()[1:].lstrip())
                    i += 1
                body_text = '\n'.join(body_lines)
                blocks.append(create_callout_block(body_text, alert_type))
                continue
            blocks.append(create_quote_block(first_body))
  • Step 6: Run test_callout_note to verify it passes
python3 test_parser.py 2>&1 | grep -A 6 "test_callout_note"

Expected: 6 ✓ checkmarks (all assertions pass).

  • Step 7: Add the remaining 4 alert-type tests

In test_parser.py, add immediately after test_callout_note:

def test_callout_tip():
    blocks = markdown_to_notion_blocks("> [!TIP]\n> Use this trick.")
    _assert(blocks[0]["type"] == "callout", "TIP block is callout")
    _assert(blocks[0]["callout"]["icon"]["emoji"] == "💡", "TIP icon is 💡")
    _assert(blocks[0]["callout"]["color"] == "green_background", "TIP color is green")


def test_callout_important():
    blocks = markdown_to_notion_blocks("> [!IMPORTANT]\n> Read this.")
    _assert(blocks[0]["callout"]["icon"]["emoji"] == "☝️", "IMPORTANT icon is ☝️")
    _assert(blocks[0]["callout"]["color"] == "purple_background", "IMPORTANT color is purple")


def test_callout_warning():
    blocks = markdown_to_notion_blocks("> [!WARNING]\n> Be careful.")
    _assert(blocks[0]["callout"]["icon"]["emoji"] == "⚠️", "WARNING icon is ⚠️")
    _assert(blocks[0]["callout"]["color"] == "yellow_background", "WARNING color is yellow")


def test_callout_caution():
    blocks = markdown_to_notion_blocks("> [!CAUTION]\n> Do not proceed.")
    _assert(blocks[0]["callout"]["icon"]["emoji"] == "🚨", "CAUTION icon is 🚨")
    _assert(blocks[0]["callout"]["color"] == "red_background", "CAUTION color is red")

Add all four to the tests list in run_all():

        test_callout_note,
        test_callout_tip,
        test_callout_important,
        test_callout_warning,
        test_callout_caution,
  • Step 8: Run the suite, expect all 5 callout tests to pass
python3 test_parser.py 2>&1 | grep -E "^test_callout|^✅"

Expected: 5 callout function names listed, then ✅ All 21 tests passed at the bottom.

  • Step 9: Add the unknown-alert fall-through test

In test_parser.py after test_callout_caution:

def test_callout_unknown_falls_through():
    """An unrecognized alert type renders as a plain quote with the marker preserved."""
    blocks = markdown_to_notion_blocks("> [!BOGUS]\n> some content")
    _assert(blocks[0]["type"] == "quote", "unknown alert renders as quote, not callout")
    quote_text = blocks[0]["quote"]["rich_text"][0]["text"]["content"]
    _assert("[!BOGUS]" in quote_text, "[!BOGUS] marker preserved in quote text")

Add to tests list right after test_callout_caution:

        test_callout_caution,
        test_callout_unknown_falls_through,
  • Step 10: Run the suite, expect 22 tests passing
python3 test_parser.py 2>&1 | tail -3

Expected: ✅ All 22 tests passed

  • Step 11: Commit
cd /Users/ourdigital/Project/our-claude-skills
git add custom-skills/32-notion-writer/code/scripts/notion_writer.py custom-skills/32-notion-writer/code/scripts/test_parser.py
git commit -m "$(cat <<'EOF'
feat(notion-writer): add GitHub-alert callout blocks

Adds support for > [!NOTE] / > [!TIP] / > [!IMPORTANT] / > [!WARNING] /
> [!CAUTION] callouts. Each alert type maps to a Notion callout block
with an emoji icon and matching colored background. Unknown alert types
(e.g. > [!BOGUS]) fall through to the existing quote handler with the
marker preserved.

+6 tests, 22 passing total.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 3: Add <details> toggle blocks

Files:

  • Modify: custom-skills/32-notion-writer/code/scripts/notion_writer.py (add factory, add detector)
  • Modify: custom-skills/32-notion-writer/code/scripts/test_parser.py (add 4 tests)

HTML5 <details> with optional <summary> produces a Notion toggle block. The body recurses through _parse_lines so any block type (lists, code, nested toggles) works inside. Multi-line layout required: <details> and </details> on their own lines.

  • Step 1: Write the basic toggle test

In test_parser.py after test_callout_unknown_falls_through:

def test_toggle_basic():
    md = "<details>\n<summary>Click to expand</summary>\n\nInner paragraph.\n</details>"
    blocks = markdown_to_notion_blocks(md)
    _assert(len(blocks) == 1, "one block emitted")
    _assert(blocks[0]["type"] == "toggle", "block type is toggle")
    summary_text = blocks[0]["toggle"]["rich_text"][0]["text"]["content"]
    _assert(summary_text == "Click to expand", "summary text preserved")
    children = blocks[0]["toggle"]["children"]
    _assert(len(children) == 1, "one child block")
    _assert(children[0]["type"] == "paragraph", "child is paragraph")

Add to tests list right after test_callout_unknown_falls_through.

  • Step 2: Run the test to verify it fails
cd /Users/ourdigital/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts
python3 test_parser.py 2>&1 | grep -A 4 "test_toggle_basic"

Expected: FAIL — <details> is rendered as paragraph(s) currently.

  • Step 3: Add create_toggle_block factory

In notion_writer.py, add after create_callout_block:

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 {
        "type": "toggle",
        "toggle": {
            "rich_text": parse_rich_text(summary_text),
            "children": children_blocks,
        },
    }
  • Step 4: Add the <details> detector at the top of _parse_lines's loop

In notion_writer.py, locate the _parse_lines function. The existing first non-empty branch is the table check. Insert the toggle detector immediately AFTER the empty-line skip and BEFORE the table check:

Find this section:

        # 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]):

Replace with:

        # Skip empty lines
        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

        # Table (header row + separator + body rows)
        if _is_table_row(line) and i + 1 < len(lines) and TABLE_SEPARATOR_RE.match(lines[i + 1]):
  • Step 5: Run test_toggle_basic to verify it passes
python3 test_parser.py 2>&1 | grep -A 5 "test_toggle_basic"

Expected: 5 ✓ checkmarks.

  • Step 6: Add the nested-blocks test

In test_parser.py after test_toggle_basic:

def test_toggle_nested_blocks():
    """Toggle body can hold any block type via _parse_lines recursion."""
    md = """<details>
<summary>Debug log</summary>

- step one
- step two

```python
print("hello")
""" blocks = markdown_to_notion_blocks(md) _assert(blocks[0]["type"] == "toggle", "block is toggle") children = blocks[0]["toggle"]["children"] types = [c["type"] for c in children] _assert("bulleted_list_item" in types, "list child preserved") _assert("code" in types, "code child preserved") code_block = next(c for c in children if c["type"] == "code") _assert(code_block["code"]["language"] == "python", "code language preserved") ```

Add to tests list after test_toggle_basic.

  • Step 7: Run nested-blocks test
python3 test_parser.py 2>&1 | grep -A 4 "test_toggle_nested_blocks"

Expected: 4 ✓ checkmarks.

  • Step 8: Add the nested-toggle test

In test_parser.py after test_toggle_nested_blocks:

def test_toggle_nested_toggle():
    """details_depth tracking allows <details> inside <details>."""
    md = """<details>
<summary>outer</summary>

<details>
<summary>inner</summary>

inner content
</details>
</details>"""
    blocks = markdown_to_notion_blocks(md)
    _assert(blocks[0]["type"] == "toggle", "outer block is toggle")
    outer_children = blocks[0]["toggle"]["children"]
    _assert(len(outer_children) == 1, "outer has exactly one child")
    _assert(outer_children[0]["type"] == "toggle", "outer child is also toggle")
    inner_summary = outer_children[0]["toggle"]["rich_text"][0]["text"]["content"]
    _assert(inner_summary == "inner", "inner summary preserved")

Add to tests list.

  • Step 9: Run nested-toggle test
python3 test_parser.py 2>&1 | grep -A 5 "test_toggle_nested_toggle"

Expected: 5 ✓ checkmarks.

  • Step 10: Add the unclosed-toggle test

In test_parser.py after test_toggle_nested_toggle:

def test_toggle_unclosed_falls_through():
    """Missing </details> at EOF degrades to paragraphs, no crash."""
    md = "<details>\n<summary>oops</summary>\n\nbody line"
    blocks = markdown_to_notion_blocks(md)
    types = [b["type"] for b in blocks]
    _assert("toggle" not in types, "no toggle emitted on unclosed <details>")
    # Body and summary should appear as paragraphs
    paragraph_texts = [
        b["paragraph"]["rich_text"][0]["text"]["content"]
        for b in blocks if b["type"] == "paragraph"
    ]
    joined = " ".join(paragraph_texts)
    _assert("oops" in joined, "summary preserved as paragraph")
    _assert("body line" in joined, "body preserved as paragraph")

Add to tests list.

  • Step 11: Run the full suite, expect 26 tests passing
python3 test_parser.py 2>&1 | tail -3

Expected: ✅ All 26 tests passed

  • Step 12: Commit
cd /Users/ourdigital/Project/our-claude-skills
git add custom-skills/32-notion-writer/code/scripts/notion_writer.py custom-skills/32-notion-writer/code/scripts/test_parser.py
git commit -m "$(cat <<'EOF'
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>
EOF
)"

Task 4: Add ::: columns blocks

Files:

  • Modify: custom-skills/32-notion-writer/code/scripts/notion_writer.py (add factory + detector)
  • Modify: custom-skills/32-notion-writer/code/scripts/test_parser.py (add 3 tests)

Pandoc fenced div ::: columns opens a Notion column_list. Each child column is delimited by ::: column and :::. Single-column blocks degrade to paragraphs (Notion requires ≥2 columns). A single depth counter tracks both nested ::: columns and column opens, decremented on every :::.

  • Step 1: Write the two-column test

In test_parser.py after test_toggle_unclosed_falls_through:

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")

Add to tests list.

  • Step 2: Run test to verify it fails
cd /Users/ourdigital/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts
python3 test_parser.py 2>&1 | grep -A 6 "test_columns_two"

Expected: FAIL — ::: columns lines render as paragraphs currently.

  • Step 3: Add create_column_list_block factory

In notion_writer.py, add after create_toggle_block:

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 {
        "type": "column_list",
        "column_list": {
            "children": [
                {"type": "column", "column": {"children": col_blocks}}
                for col_blocks in columns
            ],
        },
    }
  • Step 4: Add the ::: columns detector

In notion_writer.py, locate the toggle detector inside _parse_lines (the if line.strip() == '<details>': branch added in Task 3). Insert the columns detector immediately AFTER the toggle detector's closing continue and BEFORE the table check.

The detector uses a single depth counter that increments on either ::: columns (nested wrapper) or ::: column (column open) and decrements on ::: (closes innermost). When depth returns to 0, our wrapper is closed.

Find:

            children = _parse_lines(inner_lines)
            blocks.append(create_toggle_block(summary_text, children))
            continue

        # Table (header row + separator + body rows)

Replace with:

            children = _parse_lines(inner_lines)
            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
                import sys as _sys
                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

        # Table (header row + separator + body rows)
  • Step 5: Run test_columns_two to verify it passes
python3 test_parser.py 2>&1 | grep -A 7 "test_columns_two"

Expected: 7 ✓ checkmarks.

  • Step 6: Add the columns-with-blocks test

In test_parser.py after test_columns_two:

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")


Add to `tests` list.

- [ ] **Step 7: Add the single-column degrade test**

In `test_parser.py` after `test_columns_with_blocks`:

```python
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")

Add both new tests to the tests list right after test_columns_two:

        test_columns_two,
        test_columns_with_blocks,
        test_columns_single_degrades,
  • Step 8: Run the full suite, expect 29 tests passing
python3 test_parser.py 2>&1 | tail -3

Expected: ✅ All 29 tests passed

  • Step 9: Commit
cd /Users/ourdigital/Project/our-claude-skills
git add custom-skills/32-notion-writer/code/scripts/notion_writer.py custom-skills/32-notion-writer/code/scripts/test_parser.py
git commit -m "$(cat <<'EOF'
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.

+3 tests, 29 passing total.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 5: Add inline page mentions

Files:

  • Modify: custom-skills/32-notion-writer/code/scripts/notion_writer.py (add INLINE pattern + emit branch)
  • Modify: custom-skills/32-notion-writer/code/scripts/test_parser.py (add 3 tests)

Page mentions are inline rich-text. Pattern @[Title](id-or-url) extends INLINE_PATTERNS. Valid IDs (raw 32-hex, dashed UUID, or Notion URL) emit a mention rich-text span; invalid input degrades to plain text @Title.

  • Step 1: Write the ID-mention test

In test_parser.py after test_columns_single_degrades:

def test_mention_id():
    """@[Title](32-hex-id) produces a mention rich-text span with page reference."""
    raw_id = "abcdef0123456789abcdef0123456789"
    spans = parse_rich_text(f"see @[Roadmap]({raw_id}) for plans")
    mention_spans = [s for s in spans if s.get("type") == "mention"]
    _assert(len(mention_spans) == 1, "one mention span emitted")
    m = mention_spans[0]
    _assert(m["mention"]["type"] == "page", "mention type is page")
    _assert(m["mention"]["page"]["id"].replace("-", "") == raw_id, "page id resolved")
    _assert(m.get("plain_text") == "Roadmap", "plain_text is the title")

Add to tests list.

  • Step 2: Run to verify it fails
cd /Users/ourdigital/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts
python3 test_parser.py 2>&1 | grep -A 4 "test_mention_id"

Expected: FAIL — no mention spans emitted (currently @[...](...) is not parsed as anything special).

  • Step 3: Add the mention pattern to INLINE_PATTERNS

In notion_writer.py, find:

INLINE_PATTERNS = [
    ('code',          re.compile(r'`([^`\n]+)`')),
    ('link',          re.compile(r'\[([^\]]+)\]\(([^)\s]+)\)')),

Insert the mention entry BEFORE the link entry so it takes priority:

INLINE_PATTERNS = [
    ('code',          re.compile(r'`([^`\n]+)`')),
    ('mention',       re.compile(r'@\[([^\]]+)\]\(([^)\s]+)\)')),
    ('link',          re.compile(r'\[([^\]]+)\]\(([^)\s]+)\)')),
  • Step 4: Add the mention emit branch in parse_rich_text

In notion_writer.py, locate parse_rich_text. Find:

        if kind == 'code':
            spans.append(_rich_span(m.group(1), code=True))
        elif kind == 'link':
            link_text, link_url = m.group(1), m.group(2)
            if _is_absolute_url(link_url):

Insert the mention branch BEFORE the link branch:

        if kind == 'code':
            spans.append(_rich_span(m.group(1), code=True))
        elif kind == 'mention':
            mention_title, mention_target = m.group(1), m.group(2)
            page_id = extract_notion_id(mention_target)
            if page_id:
                spans.append({
                    "type": "mention",
                    "mention": {
                        "type": "page",
                        "page": {"id": format_id_with_dashes(page_id)},
                    },
                    "plain_text": mention_title,
                })
            else:
                # Invalid ID — degrade to plain text "@Title"
                spans.append(_rich_span(f"@{mention_title}"))
        elif kind == 'link':
            link_text, link_url = m.group(1), m.group(2)
            if _is_absolute_url(link_url):
  • Step 5: Run test_mention_id to verify it passes
python3 test_parser.py 2>&1 | grep -A 4 "test_mention_id"

Expected: 4 ✓ checkmarks.

  • Step 6: Add the URL-mention test

In test_parser.py after test_mention_id:

def test_mention_url():
    """@[Title](https://notion.so/Page-Title-id) extracts ID from URL."""
    raw_id = "abcdef0123456789abcdef0123456789"
    url = f"https://notion.so/My-Page-{raw_id}"
    spans = parse_rich_text(f"check @[My Page]({url})")
    mention_spans = [s for s in spans if s.get("type") == "mention"]
    _assert(len(mention_spans) == 1, "one mention span emitted from URL form")
    _assert(mention_spans[0]["mention"]["page"]["id"].replace("-", "") == raw_id,
            "ID extracted from Notion URL")

Add to tests list.

  • Step 7: Run URL-mention test
python3 test_parser.py 2>&1 | grep -A 3 "test_mention_url"

Expected: 2 ✓ checkmarks.

  • Step 8: Add the invalid-mention fall-back test

In test_parser.py after test_mention_url:

def test_mention_invalid_falls_back():
    """A non-resolvable target degrades to plain text '@Title' with no link."""
    spans = parse_rich_text("ping @[Bob](not-a-real-id) please")
    mention_spans = [s for s in spans if s.get("type") == "mention"]
    _assert(len(mention_spans) == 0, "no mention span for invalid id")
    plain_text_joined = "".join(
        s["text"]["content"] for s in spans if s.get("type") == "text"
    )
    _assert("@Bob" in plain_text_joined, "title rendered as plain '@Bob'")
    _assert("not-a-real-id" not in plain_text_joined, "invalid id stripped from output")

Add all three mention tests to the tests list:

        test_columns_single_degrades,
        test_mention_id,
        test_mention_url,
        test_mention_invalid_falls_back,
  • Step 9: Run the full suite, expect 32 tests passing
python3 test_parser.py 2>&1 | tail -3

Expected: ✅ All 32 tests passed

  • Step 10: Commit
cd /Users/ourdigital/Project/our-claude-skills
git add custom-skills/32-notion-writer/code/scripts/notion_writer.py custom-skills/32-notion-writer/code/scripts/test_parser.py
git commit -m "$(cat <<'EOF'
feat(notion-writer): add inline page mentions via @[Title](id-or-url)

Inline rich-text gets a new 'mention' pattern matching @[Title](target).
Target can be a raw 32-hex page ID, a dashed UUID, or a Notion URL —
all resolved via the existing extract_notion_id helper. Invalid targets
degrade to plain text '@Title' with no warning (avoids false-positive
spam from unrelated @[x](y) patterns).

+3 tests, 32 passing total.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 6: Update CLAUDE.md docs and version bump

Files:

  • Modify: custom-skills/32-notion-writer/code/CLAUDE.md (markdown table additions, examples, version footer)

Document the four new block types and bump the version from 1.1.0 → 1.2.0 with a changelog entry.

  • Step 1: Add new rows to the supported-elements table

Open custom-skills/32-notion-writer/code/CLAUDE.md and find the "Supported Elements" table:

### Supported Elements

| Markdown | Notion Block |
|----------|--------------|
| `# Heading` | Heading 1 |
| `## Heading` | Heading 2 |
| `### Heading` | Heading 3 |
| `- item` | Bulleted list |
| `1. item` | Numbered list |
| `- [ ] task` | To-do (unchecked) |
| `- [x] task` | To-do (checked) |
| `> quote` | Quote |
| `` ```code``` `` | Code block |
| `---` | Divider |
| Paragraphs | Paragraph |

Replace with:

### Supported Elements

| Markdown | Notion Block |
|----------|--------------|
| `# Heading` | Heading 1 |
| `## Heading` | Heading 2 |
| `### Heading` | Heading 3 |
| `- item` | Bulleted list |
| `1. item` | Numbered list |
| `- [ ] task` | To-do (unchecked) |
| `- [x] task` | To-do (checked) |
| `> quote` | Quote |
| `> [!NOTE]` / `[!TIP]` / `[!IMPORTANT]` / `[!WARNING]` / `[!CAUTION]` | Callout (with icon + colored background) |
| `<details><summary>X</summary> ... </details>` | Toggle (multi-line form, recursive children) |
| `::: columns / ::: column / :::` | Column list (Pandoc fenced div, ≥2 columns required) |
| `` ```code``` `` | Code block |
| `---` | Divider |
| Tables (`\| col \|`) | Table |
| Paragraphs | Paragraph |
  • Step 2: Add new rows to the inline rich-text table

In the same file, find the "Inline rich-text" table (added in Phase 2):

| Markdown | Result |
|----------|--------|
| `**bold**` or `__bold__` | bold |
| `*italic*` or `_italic_` | italic |
| `` `code` `` | inline code |
| `~~strike~~` | strikethrough |
| `[text](https://...)` | link (absolute URLs only) |
| `[text](#anchor)` | bold (Notion rejects fragment URLs) |
| `[text](relative/path.md)` | plain text (Notion rejects relative URLs) |

Replace with:

| Markdown | Result |
|----------|--------|
| `**bold**` or `__bold__` | bold |
| `*italic*` or `_italic_` | italic |
| `` `code` `` | inline code |
| `~~strike~~` | strikethrough |
| `[text](https://...)` | link (absolute URLs only) |
| `[text](#anchor)` | bold (Notion rejects fragment URLs) |
| `[text](relative/path.md)` | plain text (Notion rejects relative URLs) |
| `@[Title](page-id-or-notion-url)` | page mention (resolves via Notion URL or raw 32-hex ID) |
  • Step 3: Add new examples in the Examples section

In the same file, find the "Pipe from Another Tool" example. Insert these new examples BEFORE it:

### Callouts (GitHub alerts)

```markdown
> [!NOTE]
> Just FYI: this method is idempotent.

> [!WARNING]
> Don't run this in production without a backup.

Renders as Notion callout blocks with corresponding emoji icon and colored background.

Toggles (HTML5 <details>)

<details>
<summary>Click to expand: full debug log</summary>

```bash
$ python notion_writer.py --test
✅ Connected

Lists, code blocks, and even nested <details> work inside.

```

Multi-line form required: <details> and </details> must be on their own lines.

Columns (Pandoc fenced div)

::: columns
::: column
**Column 1**

- item a
- item b
:::
::: column
**Column 2**

```python
print("hello")

::: :::


≥2 columns required by Notion. Single-column blocks degrade to plain paragraphs.

### Page mentions

```markdown
See @[Architecture Decision Record](https://notion.so/ADR-abcdef0123456789abcdef0123456789) for context.

Both Notion URLs and raw 32-hex IDs work. Invalid targets fall back to plain text @Title.


- [ ] **Step 4: Bump version footer**

In the same file, find the version footer:

```markdown
*Version 1.1.0 | Claude Code | 2026-04-27*

Changelog:
- 1.1.0 — Migrated to Notion API 2025-09-03 (multi-source databases). Added `--properties` JSON flag, `--upsert-by` for idempotency, anchor-link parser fix, friendlier API error messages.
- 1.0.0 — Initial release with markdown→Notion block conversion.

Replace with:

*Version 1.2.0 | Claude Code | 2026-04-27*

Changelog:
- 1.2.0 — Extended block coverage: GitHub-alert callouts, HTML5 `<details>` toggles, Pandoc `::: columns` fenced div, inline `@[Title](id-or-url)` page mentions. Parser made reentrant to support full recursion inside container blocks.
- 1.1.0 — Migrated to Notion API 2025-09-03 (multi-source databases). Added `--properties` JSON flag, `--upsert-by` for idempotency, anchor-link parser fix, friendlier API error messages.
- 1.0.0 — Initial release with markdown→Notion block conversion.
  • Step 5: Verify the docs file is well-formed
cd /Users/ourdigital/Project/our-claude-skills
python3 -c "
content = open('custom-skills/32-notion-writer/code/CLAUDE.md').read()
assert '> [!NOTE]' in content, 'callout doc added'
assert '<details>' in content, 'toggle doc added'
assert '::: columns' in content, 'columns doc added'
assert '@[Title](page-id-or-notion-url)' in content, 'mention doc added'
assert '1.2.0' in content, 'version bumped'
print('CLAUDE.md updated correctly')
"

Expected: CLAUDE.md updated correctly

  • Step 6: Run the full test suite one last time
cd /Users/ourdigital/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts
python3 test_parser.py 2>&1 | tail -3

Expected: ✅ All 32 tests passed

  • Step 7: Commit
cd /Users/ourdigital/Project/our-claude-skills
git add custom-skills/32-notion-writer/code/CLAUDE.md
git commit -m "$(cat <<'EOF'
docs(notion-writer): document Phase 3c block coverage + bump to v1.2.0

Adds rows for callouts, toggles, columns, and page mentions in the
supported-elements and inline rich-text tables. Adds usage examples
for each. Updates version footer with changelog entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Final verification

After all six tasks are complete:

  • Run the full test suite: 32 tests passing
  • Sanity-check imports: python3 -c "from notion_writer import markdown_to_notion_blocks, create_callout_block, create_toggle_block, create_column_list_block; print('all factories importable')"
  • Confirm git log shows 6 new commits since c66b5e1 (the spec commit), one per task

Out-of-scope follow-ups (for Phase 3b/3a)

  • Round-trip tests (markdown → blocks → markdown) — deferred to Phase 3b when the reverse converter exists
  • Image upload, bookmark, embed, equation, synced block — added when concrete use cases demand them
  • Single-line <details><summary>X</summary>body</details> — not supported in v1; multi-line form required