diff --git a/custom-skills/32-notion-writer/code/scripts/md_translate.py b/custom-skills/32-notion-writer/code/scripts/md_translate.py new file mode 100644 index 0000000..c280b7c --- /dev/null +++ b/custom-skills/32-notion-writer/code/scripts/md_translate.py @@ -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'' + f'{title}') + 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'') + out.extend(_indent([_translate_mentions(b) for b in body])) + out.append("") + 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("") + for col in cols: + out.append("\t") + out.extend(_indent(_indent( + [_translate_mentions(c) for c in col]))) + out.append("\t") + out.append("") + continue + + # Toggle:
.. body
+ if line.strip() == "
": + out.append("
") + i += 1 + if i < len(lines) and lines[i].lstrip().startswith(""): + out.append(lines[i].strip()) + i += 1 + body = [] + while i < len(lines) and lines[i].strip() != "
": + body.append(_translate_mentions(lines[i])) + i += 1 + out.extend(_indent(body)) + out.append("
") + if i < len(lines): # skip closing + i += 1 + continue + + out.append(_translate_mentions(line)) + i += 1 + return "\n".join(out) diff --git a/custom-skills/32-notion-writer/code/scripts/test_md_translate.py b/custom-skills/32-notion-writer/code/scripts/test_md_translate.py new file mode 100644 index 0000000..e55c8a7 --- /dev/null +++ b/custom-skills/32-notion-writer/code/scripts/test_md_translate.py @@ -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 '' in out + assert "\tBe careful" in out + assert "" 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 "" in out + assert out.count("") == 2 + assert "" in out + assert "\tLeft" in out or "\t\tLeft" in out + + +def test_toggle_children_indented(): + src = "
\nMore\nBody line\n
" + out = md_translate.translate(src) + assert "More" in out + assert "\tBody line" in out + + +def test_mention_url(): + out = md_translate.translate( + "See @[ADR](https://notion.so/X-abcdef0123456789abcdef0123456789).") + assert "ADR" 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()