Brainstorming output for the first of three Phase 3 sub-projects: adding callout, toggle, column, and page-mention block support to notion_writer.py's markdown→Notion parser. Locks four architectural decisions reached during brainstorming: - Hybrid syntax (GitHub alerts / <details> / Pandoc fenced div) - Full recursion for container blocks (toggles + columns) - ID-or-URL for inline page mentions - Reentrant flat parser (additive, ~150 LOC) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
230 lines
12 KiB
Markdown
230 lines
12 KiB
Markdown
# Notion Writer (Skill 32) — Extended Block Coverage Design
|
||
|
||
> **Date**: 2026-04-27
|
||
> **Status**: Approved (brainstorming)
|
||
> **Scope**: Phase 3c — add callout, toggle, column, and page-mention block support to `notion_writer.py`'s markdown→Notion parser
|
||
> **Sequence**: First of three Phase 3 sub-projects (3c → 3b → 3a per user-approved order)
|
||
> **Predecessor**: Phase 2 commit `144a17c` (multi-source data API migration)
|
||
|
||
---
|
||
|
||
## Goal
|
||
|
||
Extend the markdown→Notion-blocks converter in `custom-skills/32-notion-writer/code/scripts/notion_writer.py` to support four block types currently unhandled: **callouts**, **toggles**, **columns**, and **inline page mentions**. The parser remains pure (no API calls during parse), preserves existing block-type behavior unchanged, and stays under the project's "small, well-tested" line-scanner architecture.
|
||
|
||
This work also lays the foundation for Phase 3b (Notion-as-RAG export), which needs a clean blocks→markdown reverse converter; the syntax chosen here must round-trip cleanly.
|
||
|
||
---
|
||
|
||
## Non-goals
|
||
|
||
- Notion blocks not in the four-item list above (image upload, bookmark, embed, equation, breadcrumb, table-of-contents, synced block — defer until a concrete use case demands them).
|
||
- LLM-driven content distillation (belongs to Phase 3b downstream of `90-reference-curator`).
|
||
- Search-by-title page-mention resolution (rejected during brainstorming for purity reasons; explicit ID/URL only).
|
||
- Cross-block-type round-trip tests (deferred to Phase 3b when the reverse converter exists).
|
||
|
||
---
|
||
|
||
## Architectural decisions (locked during brainstorming)
|
||
|
||
| Decision | Choice | Rationale |
|
||
|---|---|---|
|
||
| Syntax style | **Hybrid**: GitHub alerts (`> [!NOTE]`) for callouts, HTML5 `<details>` for toggles, Pandoc fenced div (`::: columns`) for columns | Each element uses the most-portable syntax available; renders correctly on GitHub for the formats that have native support |
|
||
| Container nesting | **Full recursion** — anything legal at top level is legal inside a toggle or column, including nested toggles/columns | Dev logs commonly put code blocks and lists inside toggles; the limitation is unacceptable |
|
||
| Page-mention syntax | **ID-or-URL** — `@[Title](page-id)` and `@[Title](https://notion.so/Page-id)` both accepted | URL form is what users naturally paste; ID form is what 3b's reverse converter emits |
|
||
| Implementation shape | **Reentrant flat parser** — `markdown_to_notion_blocks` accepts string or list of lines; container detectors recurse on inner content | Smallest diff, preserves existing line-scanner architecture, additive |
|
||
|
||
---
|
||
|
||
## Public API
|
||
|
||
`markdown_to_notion_blocks` becomes reentrant by widening its parameter type:
|
||
|
||
```python
|
||
def markdown_to_notion_blocks(content: Union[str, List[str]]) -> List[Dict[str, Any]]:
|
||
lines = content.splitlines() if isinstance(content, str) else list(content)
|
||
return _parse_lines(lines)
|
||
```
|
||
|
||
The current loop body moves into a new private `_parse_lines(lines: List[str])`. All existing callers continue to pass strings; nothing breaks.
|
||
|
||
---
|
||
|
||
## New block factories
|
||
|
||
Added alongside existing `create_*_block` helpers:
|
||
|
||
| Factory | Output shape |
|
||
|---|---|
|
||
| `create_callout_block(rich_text_spans, alert_type)` | `{"type": "callout", "callout": {"rich_text": ..., "icon": {"type": "emoji", "emoji": "ℹ️"}, "color": "blue_background"}}` |
|
||
| `create_toggle_block(summary_spans, children_blocks)` | `{"type": "toggle", "toggle": {"rich_text": ..., "children": ...}}` |
|
||
| `create_column_list_block(column_blocks_lists)` | `{"type": "column_list", "column_list": {"children": [{"type": "column", "column": {"children": [...]}}]}}` |
|
||
|
||
Page mentions are inline rich-text — no new block factory; one new pattern in `INLINE_PATTERNS`.
|
||
|
||
---
|
||
|
||
## Constants
|
||
|
||
Added at the top of the file alongside `INLINE_PATTERNS`:
|
||
|
||
```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*$')
|
||
```
|
||
|
||
The icons match GitHub's render (shape and hue); colors slot into Notion's named-color palette directly.
|
||
|
||
---
|
||
|
||
## Block-type details
|
||
|
||
### Callout
|
||
|
||
- **Detect**: blockquote line where the first content line (after `>`) matches `ALERT_RE`
|
||
- **Body**: collect subsequent contiguous `>` lines until a non-`>` line; concatenate with `\n` separators into one rich-text run (matches GitHub's render — alerts are single-block, no nested lists/code). Body text passes through `parse_rich_text` so bold/italic/code/links/mentions work inside the callout.
|
||
- **Unknown alert type** (e.g., `> [!FOO]`): fall through to existing quote handling, `[!FOO]` preserved as text
|
||
- **Notion shape**: callout block with `rich_text`, `icon.emoji`, `color`
|
||
|
||
### Toggle
|
||
|
||
- **Detect**: line equals `<details>` (whitespace-tolerant). Single-line forms like `<details><summary>X</summary>body</details>` are **not supported** in v1 — require the multi-line layout (open tag on its own line). Most markdown editors that emit `<details>` already use multi-line.
|
||
- **Summary**: next non-blank line if it matches `<summary>(.*)</summary>`; if absent, summary is empty rich-text. Summary text passes through `parse_rich_text` so bold/italic/code/links/mentions work inside it.
|
||
- **Body**: lines between `</summary>` (or first non-summary line) and the matching `</details>`, depth-tracked via `details_depth` int so `<details>` inside `<details>` works correctly
|
||
- **Recurse**: body lines fed to `_parse_lines`, result attached as `children`
|
||
- **Unclosed** at EOF: emit body lines as plain paragraphs, one-line stderr warning
|
||
|
||
### Columns
|
||
|
||
- **Detect**: line equals `::: columns`
|
||
- **Inner separators**: `::: column` begins each child column
|
||
- **Close**: bare `:::` closes either a column or the wrapper, depending on `colon_depth`
|
||
- **Recurse**: each column's lines fed to `_parse_lines`, results attached as `children` of `column` blocks; `column` blocks attached as `children` of `column_list`
|
||
- **Validation**: ≥2 columns required by Notion; single-column `::: columns` block degrades to plain paragraphs (skip wrapper)
|
||
- **Unclosed** at EOF: emit lines as plain paragraphs, one-line stderr warning
|
||
|
||
### Page mentions (inline rich-text)
|
||
|
||
- **Pattern**: new entry in `INLINE_PATTERNS`: `('mention', re.compile(r'@\[([^\]]+)\]\(([^)\s]+)\)'))`
|
||
- **Resolution**: `extract_notion_id(target)` reuses existing logic — accepts raw 32-char hex, dashed UUID, or full Notion URL
|
||
- **Valid ID**: rich-text span with shape:
|
||
```python
|
||
{
|
||
"type": "mention",
|
||
"mention": {"type": "page", "page": {"id": resolved_id}},
|
||
"plain_text": title_text,
|
||
}
|
||
```
|
||
- **Invalid ID** (extract returns None): plain text `@Title` with no link annotation, no warning (graceful — many false positives are possible with `@`)
|
||
|
||
---
|
||
|
||
## Recursion mechanics
|
||
|
||
The line-scanning loop in `_parse_lines` gets three new detectors, ordered before existing ones:
|
||
|
||
```
|
||
for line in lines:
|
||
if in_code_fence: ... # existing — wins over all
|
||
if in_table: ... # existing
|
||
if line == '<details>': ... # NEW — capture to </details>, recurse
|
||
if line == '::: columns': ... # NEW — capture columns, recurse per column
|
||
if line.startswith('>') and ALERT_RE.match(rest): ... # NEW — collect callout body
|
||
# fall through to existing detectors (heading/list/quote/code-fence/table/etc.)
|
||
```
|
||
|
||
Recursion algorithm for the two recursive container types:
|
||
|
||
1. Detect open delimiter at line *i*
|
||
2. Scan forward to matching close, tracking depth for nested same-kind delimiters (`<details>`/`</details>` for toggles, `:::`/`::: columns` for columns)
|
||
3. Slice inner lines into a sub-list (per-column sub-lists for columns)
|
||
4. Recursively call `_parse_lines(sub_lines)`
|
||
5. Wrap result in the appropriate Notion container block
|
||
6. Advance the outer loop index past the close
|
||
|
||
State held by the scanner: two ints (`details_depth`, `colon_depth`), since the two container kinds don't share delimiters with each other or with existing scanner state.
|
||
|
||
---
|
||
|
||
## Error handling
|
||
|
||
Permissive philosophy — never fail the push, degrade to readable text on malformed input. Same posture as the Phase 2 anchor-link fix.
|
||
|
||
| Failure | Behavior |
|
||
|---|---|
|
||
| Unclosed `<details>` at EOF | Emit body lines as paragraphs; one-line stderr warning |
|
||
| Unclosed `::: columns` at EOF | Same |
|
||
| Single-column `::: columns` block | Degrade to paragraphs (skip wrapper, no warning — caller's choice) |
|
||
| Unknown alert type `> [!FOO]` | Fall through to existing quote rendering, preserving `[!FOO]` text |
|
||
| Invalid mention ID | Plain text `@Title`, no warning (avoid false-positive spam from any `@[x](y)` pattern) |
|
||
| Missing `<summary>` in `<details>` | Empty summary, body parses normally |
|
||
|
||
---
|
||
|
||
## Testing
|
||
|
||
Twelve new tests added to `test_parser.py`, bringing total from 16 → 28. Round-trip (markdown → blocks → markdown) tests deferred to Phase 3b.
|
||
|
||
| Test | What it verifies |
|
||
|---|---|
|
||
| `test_callout_note` | `> [!NOTE]` emits `callout` with ℹ️ icon and `blue_background` |
|
||
| `test_callout_tip` | `> [!TIP]` → 💡, green |
|
||
| `test_callout_important` | `> [!IMPORTANT]` → ☝️, purple |
|
||
| `test_callout_warning` | `> [!WARNING]` → ⚠️, yellow |
|
||
| `test_callout_caution` | `> [!CAUTION]` → 🚨, red |
|
||
| `test_callout_unknown_falls_through` | `> [!BOGUS]` becomes a quote, `[!BOGUS]` preserved as text |
|
||
| `test_toggle_basic` | `<details><summary>X</summary>body</details>` → toggle with summary X and paragraph children |
|
||
| `test_toggle_nested_blocks` | Toggle containing list + code block (validates `_parse_lines` recursion) |
|
||
| `test_toggle_nested_toggle` | Toggle inside toggle (validates `details_depth` tracking) |
|
||
| `test_toggle_unclosed_falls_through` | Missing `</details>` produces text paragraphs, no crash |
|
||
| `test_columns_two` | Basic two-column layout with paragraphs |
|
||
| `test_columns_with_blocks` | Columns containing lists/code (validates per-column recursion) |
|
||
| `test_columns_single_degrades` | Single-column `::: columns` block emits paragraphs, no `column_list` |
|
||
| `test_mention_id` | `@[Title](32-hex-id)` produces `mention` rich-text span |
|
||
| `test_mention_url` | `@[Title](https://notion.so/Title-id)` resolves to ID |
|
||
| `test_mention_invalid_falls_back` | Bad ID → plain text `@Title`, no link/mention annotation |
|
||
|
||
(Test count is 16 in this list because the 5 callouts are listed individually; some may collapse into parametrized tests during implementation. Total stays ≥12 new.)
|
||
|
||
---
|
||
|
||
## File changes
|
||
|
||
| File | Change |
|
||
|---|---|
|
||
| `custom-skills/32-notion-writer/code/scripts/notion_writer.py` | Refactor `markdown_to_notion_blocks` to be reentrant; add `_parse_lines`; add 3 block factories + 1 inline pattern; add `ALERT_TYPES` and `ALERT_RE` constants |
|
||
| `custom-skills/32-notion-writer/code/scripts/test_parser.py` | +12 tests; total 28 |
|
||
| `custom-skills/32-notion-writer/code/CLAUDE.md` | Document new syntax in the Markdown Support section; bump version footer to 1.2.0 with changelog entry |
|
||
|
||
No new files. No new dependencies. The parser remains pure.
|
||
|
||
---
|
||
|
||
## Out of scope (parking lot)
|
||
|
||
- **3b — Notion-as-RAG export**: requires a blocks → markdown reverse converter that emits the same syntax this design defines. The hybrid syntax was chosen partly to round-trip cleanly. Implementation deferred.
|
||
- **3a — Metadata-aware migration**: cross-database moves with auto-mapped properties, dry-run diff, type-aware transforms. Builds on `31-notion-organizer/scripts/schema_migrator.py`. Deferred.
|
||
- **Block-type expansion** beyond the four named: image (upload), bookmark, embed, equation, synced block, breadcrumb, table-of-contents — added when concrete use cases demand them.
|
||
|
||
---
|
||
|
||
## Implementation transition
|
||
|
||
After this spec is approved by the user, transition to `superpowers:writing-plans` to produce the step-by-step implementation plan. The plan will likely break into:
|
||
|
||
1. Refactor `markdown_to_notion_blocks` to reentrant form (no behavior change; verify all 16 existing tests still pass)
|
||
2. Add callout detector + factory + 6 callout tests
|
||
3. Add toggle detector + factory + 4 toggle tests (depth tracking covered here)
|
||
4. Add columns detector + factory + 3 columns tests (depth tracking covered here)
|
||
5. Add page-mention inline pattern + 3 mention tests
|
||
6. Update `notion_writer.py` CLAUDE.md docs + version bump
|
||
7. Run full test suite (28 expected); commit
|
||
|
||
Each step is independently testable and revertable.
|