From e0f93e6add11eb8c467044e292fc466425155d7b Mon Sep 17 00:00:00 2001 From: Andrew Yim Date: Mon, 27 Apr 2026 11:12:50 +0900 Subject: [PATCH] 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. Also restores the "must come before generic bullet match" comment on the todo branch (load-bearing ordering note). +6 tests, 22 passing total. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../code/scripts/notion_writer.py | 39 +++++++++++++- .../code/scripts/test_parser.py | 52 +++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/custom-skills/32-notion-writer/code/scripts/notion_writer.py b/custom-skills/32-notion-writer/code/scripts/notion_writer.py index 15211f2..8bbb20f 100644 --- a/custom-skills/32-notion-writer/code/scripts/notion_writer.py +++ b/custom-skills/32-notion-writer/code/scripts/notion_writer.py @@ -122,6 +122,7 @@ def _parse_lines(lines: List[str]) -> List[Dict[str, Any]]: 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)) @@ -134,9 +135,21 @@ def _parse_lines(lines: List[str]) -> List[Dict[str, Any]]: elif re.match(r'^\d+\.\s', line.strip()): text = re.sub(r'^\d+\.\s', '', line.strip()) blocks.append(create_numbered_list_block(text)) + # Blockquote — GitHub-alert callout takes priority over generic quote elif line.startswith('>'): - text = line[1:].strip() - blocks.append(create_quote_block(text)) + 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)) elif line.strip() in ['---', '***', '___']: blocks.append(create_divider_block()) else: @@ -155,6 +168,15 @@ INLINE_PATTERNS = [ ('italic_under', re.compile(r'(? Dict[str, Any]: } +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, + }, + } + + def create_code_block(code: str, language: str) -> Dict[str, Any]: return { "object": "block", diff --git a/custom-skills/32-notion-writer/code/scripts/test_parser.py b/custom-skills/32-notion-writer/code/scripts/test_parser.py index 899b41b..d0dca63 100644 --- a/custom-skills/32-notion-writer/code/scripts/test_parser.py +++ b/custom-skills/32-notion-writer/code/scripts/test_parser.py @@ -165,6 +165,52 @@ def test_blocks_table(): "link inline formatting preserved inside table cell") +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") + + +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") + + +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") + + def test_rich_text_anchor_link_becomes_bold(): """TOC-style fragment links must not be sent as Notion link annotations.""" spans = parse_rich_text("see [Section A](#section-a) below") @@ -214,6 +260,12 @@ def run_all(): test_blocks_todo, test_blocks_quote_code_divider, test_blocks_table, + test_callout_note, + test_callout_tip, + test_callout_important, + test_callout_warning, + test_callout_caution, + test_callout_unknown_falls_through, test_rich_text_anchor_link_becomes_bold, test_rich_text_relative_link_becomes_plain, test_rich_text_absolute_link_preserved,