From e2ae8aad946c8f155ffae68bc0113b66ef785c05 Mon Sep 17 00:00:00 2001 From: Andrew Yim Date: Mon, 27 Apr 2026 11:03:39 +0900 Subject: [PATCH] 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) --- ...7-notion-writer-extended-block-coverage.md | 1357 +++++++++++++++++ 1 file changed, 1357 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-27-notion-writer-extended-block-coverage.md diff --git a/docs/superpowers/plans/2026-04-27-notion-writer-extended-block-coverage.md b/docs/superpowers/plans/2026-04-27-notion-writer-extended-block-coverage.md new file mode 100644 index 0000000..d4bdf9e --- /dev/null +++ b/docs/superpowers/plans/2026-04-27-notion-writer-extended-block-coverage.md @@ -0,0 +1,1357 @@ +# 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