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>
1358 lines
46 KiB
Markdown
1358 lines
46 KiB
Markdown
# 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
|