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

1358 lines
46 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**
```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) <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:
```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) <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`:
```python
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**
```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 — `<details>` 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 `<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:
```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 <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**
```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 = """<details>
<summary>Debug log</summary>
- step one
- step two
```python
print("hello")
```
</details>"""
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 <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**
```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 </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**
```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 <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`:
```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() == '<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:
```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) <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`:
```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) <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:
```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) |
| `<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
| 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 `<details>`)
```markdown
<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.
</details>
```
Multi-line form required: `<details>` and `</details>` 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 `<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**
```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 '<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**
```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) <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