# 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 `
` 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** ```bash 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: ```python from typing import Optional, List, Dict, Any ``` to: ```python 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: ```python 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** ```bash python3 test_parser.py ``` Expected: `✅ All 16 tests passed` - [ ] **Step 5: Verify the reentrant signature works (sanity check)** ```bash 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** ```bash 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) 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: ```python 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`): ```python 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** ```bash 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`): ```python 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): ```python 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`: ```python # Blockquote elif line.startswith('>'): text = line[1:].strip() blocks.append(create_quote_block(text)) ``` Replace it with: ```python # 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** ```bash 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`: ```python 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()`: ```python 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** ```bash 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`: ```python 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`: ```python test_callout_caution, test_callout_unknown_falls_through, ``` - [ ] **Step 10: Run the suite, expect 22 tests passing** ```bash python3 test_parser.py 2>&1 | tail -3 ``` Expected: `✅ All 22 tests passed` - [ ] **Step 11: Commit** ```bash 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) EOF )" ``` --- ## Task 3: Add `
` 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 `
` with optional `` 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: `
` and `
` on their own lines. - [ ] **Step 1: Write the basic toggle test** In `test_parser.py` after `test_callout_unknown_falls_through`: ```python def test_toggle_basic(): md = "
\nClick to expand\n\nInner paragraph.\n
" 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** ```bash 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 — `
` is rendered as paragraph(s) currently. - [ ] **Step 3: Add `create_toggle_block` factory** In `notion_writer.py`, add after `create_callout_block`: ```python 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 `
` 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: ```python # 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: ```python # Skip empty lines if not line.strip(): i += 1 continue # Toggle (HTML5
) — multi-line form, depth-tracked for nesting if line.strip() == '
': i += 1 # Optional ... 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*(.*)\s*$', lines[i]) if summary_match: summary_text = summary_match.group(1) i += 1 # Collect body lines until matching
, depth-tracked inner_lines: List[str] = [] depth = 1 while i < len(lines) and depth > 0: stripped_inner = lines[i].strip() if stripped_inner == '
': depth += 1 inner_lines.append(lines[i]) elif stripped_inner == '
': depth -= 1 if depth > 0: inner_lines.append(lines[i]) else: inner_lines.append(lines[i]) i += 1 if depth > 0: # Unclosed
at EOF — degrade to paragraphs import sys as _sys print("Warning: unclosed
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** ```bash 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`: ```python def test_toggle_nested_blocks(): """Toggle body can hold any block type via _parse_lines recursion.""" md = """
Debug log - 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** ```bash 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`: ```python def test_toggle_nested_toggle(): """details_depth tracking allows
inside
.""" md = """
outer
inner inner content
""" 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** ```bash 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`: ```python def test_toggle_unclosed_falls_through(): """Missing
at EOF degrades to paragraphs, no crash.""" md = "
\noops\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
") # 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** ```bash python3 test_parser.py 2>&1 | tail -3 ``` Expected: `✅ All 26 tests passed` - [ ] **Step 12: Commit** ```bash 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
Multi-line
...
with optional emits a Notion toggle block. Body recurses through _parse_lines so any block type (lists, code, nested toggles) is supported inside. Depth tracking lets
nest inside
. Unclosed
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) 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`: ```python 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** ```bash 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`: ```python 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() == '
':` 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: ```python children = _parse_lines(inner_lines) blocks.append(create_toggle_block(summary_text, children)) continue # Table (header row + separator + body rows) ``` Replace with: ```python 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** ```bash 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`: ```python 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`: ```python test_columns_two, test_columns_with_blocks, test_columns_single_degrades, ``` - [ ] **Step 8: Run the full suite, expect 29 tests passing** ```bash python3 test_parser.py 2>&1 | tail -3 ``` Expected: `✅ All 29 tests passed` - [ ] **Step 9: Commit** ```bash 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) 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`: ```python 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** ```bash 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: ```python INLINE_PATTERNS = [ ('code', re.compile(r'`([^`\n]+)`')), ('link', re.compile(r'\[([^\]]+)\]\(([^)\s]+)\)')), ``` Insert the `mention` entry BEFORE the `link` entry so it takes priority: ```python 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: ```python 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: ```python 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** ```bash 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`: ```python 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** ```bash 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`: ```python 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: ```python 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** ```bash python3 test_parser.py 2>&1 | tail -3 ``` Expected: `✅ All 32 tests passed` - [ ] **Step 10: Commit** ```bash 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) 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: ```markdown ### 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: ```markdown ### 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) | | `
X ...
` | 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 | 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 | 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: ```markdown ### 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 `
`) ```markdown
Click to expand: full debug log ```bash $ python notion_writer.py --test ✅ Connected ``` Lists, code blocks, and even nested `
` work inside.
``` Multi-line form required: `
` and `
` must be on their own lines. ### Columns (Pandoc fenced div) ```markdown ::: 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: ```markdown *Version 1.2.0 | Claude Code | 2026-04-27* Changelog: - 1.2.0 — Extended block coverage: GitHub-alert callouts, HTML5 `
` 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** ```bash 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 '
' 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** ```bash 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** ```bash 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) 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 `
Xbody
`** — not supported in v1; multi-line form required