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:
121
custom-skills/32-notion-writer/code/scripts/md_translate.py
Normal file
121
custom-skills/32-notion-writer/code/scripts/md_translate.py
Normal 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)
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user