feat(notion-writer): dialect->Notion enhanced markdown translator

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-27 09:41:56 +09:00
parent 337f2ad6e1
commit dfbc52e531
2 changed files with 199 additions and 0 deletions

View File

@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Translate the notion-writer markdown dialect into Notion enhanced
markdown for the markdown write engine. Pure functions, no I/O."""
from __future__ import annotations
import re
from typing import List
# NOTE: `notion_writer` is imported lazily inside _translate_mentions to avoid
# a circular import (notion_writer imports this module at its top level).
# Skill alert type -> (emoji, Notion enhanced-markdown background color)
CALLOUT_MAP = {
"NOTE": ("", "blue_bg"),
"TIP": ("💡", "green_bg"),
"IMPORTANT": ("☝️", "purple_bg"),
"WARNING": ("⚠️", "yellow_bg"),
"CAUTION": ("🚨", "red_bg"),
}
_ALERT_RE = re.compile(r'^\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*$')
_MENTION_RE = re.compile(r'@\[([^\]]+)\]\(([^)\s]+)\)')
def _translate_mentions(text: str) -> str:
from notion_writer import extract_notion_id, format_id_with_dashes # lazy: breaks import cycle
def repl(m: "re.Match") -> str:
title, target = m.group(1), m.group(2)
page_id = extract_notion_id(target)
if page_id:
return (f'<mention-page url="{format_id_with_dashes(page_id)}">'
f'{title}</mention-page>')
return f"@{title}"
return _MENTION_RE.sub(repl, text)
def _indent(lines: List[str]) -> List[str]:
return ["\t" + ln if ln.strip() else ln for ln in lines]
def translate(content: str) -> str:
lines = content.split("\n")
out: List[str] = []
i = 0
while i < len(lines):
line = lines[i]
# Callout: > [!TYPE] then contiguous > body lines
if line.lstrip().startswith(">"):
body_first = line.lstrip()[1:].strip()
alert = _ALERT_RE.match(body_first)
if alert:
emoji, color = CALLOUT_MAP[alert.group(1)]
i += 1
body: List[str] = []
while i < len(lines) and lines[i].lstrip().startswith(">"):
body.append(lines[i].lstrip()[1:].lstrip())
i += 1
out.append(f'<callout icon="{emoji}" color="{color}">')
out.extend(_indent([_translate_mentions(b) for b in body]))
out.append("</callout>")
continue
# Columns: ::: columns / ::: column / :::
# State machine: "::: column" opens a column; ":::" closes a column
# when one is open, otherwise closes the wrapper. The Pandoc layout
# emits N "::: column" opens, N ":::" column-closes, then one final
# ":::" wrapper-close.
if line.strip() == "::: columns":
i += 1
cols: List[List[str]] = []
cur: List[str] = None
while i < len(lines):
s = lines[i].strip()
if s == "::: column":
if cur is not None:
cols.append(cur)
cur = []
i += 1
elif s == ":::":
if cur is not None: # close the open column
cols.append(cur)
cur = None
i += 1
else: # close the wrapper
i += 1
break
else:
if cur is not None:
cur.append(lines[i])
i += 1
out.append("<columns>")
for col in cols:
out.append("\t<column>")
out.extend(_indent(_indent(
[_translate_mentions(c) for c in col])))
out.append("\t</column>")
out.append("</columns>")
continue
# Toggle: <details><summary>..</summary> body </details>
if line.strip() == "<details>":
out.append("<details>")
i += 1
if i < len(lines) and lines[i].lstrip().startswith("<summary>"):
out.append(lines[i].strip())
i += 1
body = []
while i < len(lines) and lines[i].strip() != "</details>":
body.append(_translate_mentions(lines[i]))
i += 1
out.extend(_indent(body))
out.append("</details>")
if i < len(lines): # skip closing </details>
i += 1
continue
out.append(_translate_mentions(line))
i += 1
return "\n".join(out)

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Tests for md_translate.py — run with `python test_md_translate.py`."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
import md_translate
def test_passthrough_basics():
src = "# Heading\n\n- item\n\n```python\nx=1\n```"
assert md_translate.translate(src) == src
def test_callout_note():
out = md_translate.translate("> [!NOTE]\n> Be careful")
assert '<callout icon="" color="blue_bg">' in out
assert "\tBe careful" in out
assert "</callout>" in out
def test_callout_warning_color():
out = md_translate.translate("> [!WARNING]\n> Danger")
assert 'color="yellow_bg"' in out
assert 'icon="⚠️"' in out
def test_columns():
src = "::: columns\n::: column\nLeft\n:::\n::: column\nRight\n:::\n:::"
out = md_translate.translate(src)
assert "<columns>" in out
assert out.count("<column>") == 2
assert "</columns>" in out
assert "\tLeft" in out or "\t\tLeft" in out
def test_toggle_children_indented():
src = "<details>\n<summary>More</summary>\nBody line\n</details>"
out = md_translate.translate(src)
assert "<summary>More</summary>" in out
assert "\tBody line" in out
def test_mention_url():
out = md_translate.translate(
"See @[ADR](https://notion.so/X-abcdef0123456789abcdef0123456789).")
assert "<mention-page url=" in out
assert ">ADR</mention-page>" in out
def test_mention_invalid_plain():
out = md_translate.translate("ping @[Bob](not-an-id)")
assert "@Bob" in out
assert "mention-page" not in out
def run_all():
tests = [
test_passthrough_basics,
test_callout_note,
test_callout_warning_color,
test_columns,
test_toggle_children_indented,
test_mention_url,
test_mention_invalid_plain,
]
for t in tests:
print(f"\n{t.__name__}")
t()
print("\n" + "=" * 50)
print(f"✅ All {len(tests)} tests passed")
print("=" * 50)
if __name__ == "__main__":
run_all()