feat(notion-writer): add inline page mentions via @[Title](id-or-url)
Inline rich-text gets a new 'mention' pattern matching @[Title](target).
Target can be a raw 32-hex page ID, a dashed UUID, or a Notion URL —
all resolved via the existing extract_notion_id helper. Invalid targets
degrade to plain text '@Title' with no warning (avoids false-positive
spam from unrelated @[x](y) patterns).
Pattern placed BEFORE 'link' in INLINE_PATTERNS so the @ prefix takes
priority over plain link parsing.
Also widens extract_notion_id's URL regex from [^-]+- to [^/]+?- so
multi-hyphen Notion URLs (e.g. notion.so/My-Cool-Page-{id}) resolve
correctly — previous regex only matched single-segment titles.
+3 tests, 32 passing total.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -34,9 +34,9 @@ def extract_notion_id(url_or_id: str) -> Optional[str]:
|
|||||||
# Notion URL patterns
|
# Notion URL patterns
|
||||||
patterns = [
|
patterns = [
|
||||||
r'notion\.so/(?:[^/]+/)?([a-f0-9]{32})', # notion.so/workspace/page-id
|
r'notion\.so/(?:[^/]+/)?([a-f0-9]{32})', # notion.so/workspace/page-id
|
||||||
r'notion\.so/(?:[^/]+/)?[^-]+-([a-f0-9]{32})', # notion.so/workspace/Page-Title-id
|
r'notion\.so/(?:[^/]+/)?[^/]+?-([a-f0-9]{32})', # notion.so/workspace/Page-Title-id
|
||||||
r'notion\.site/(?:[^/]+/)?([a-f0-9]{32})', # public notion.site
|
r'notion\.site/(?:[^/]+/)?([a-f0-9]{32})', # public notion.site
|
||||||
r'notion\.site/(?:[^/]+/)?[^-]+-([a-f0-9]{32})',
|
r'notion\.site/(?:[^/]+/)?[^/]+?-([a-f0-9]{32})',
|
||||||
r'([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})', # UUID format
|
r'([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})', # UUID format
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -277,6 +277,7 @@ def _parse_lines(lines: List[str]) -> List[Dict[str, Any]]:
|
|||||||
|
|
||||||
INLINE_PATTERNS = [
|
INLINE_PATTERNS = [
|
||||||
('code', re.compile(r'`([^`\n]+)`')),
|
('code', re.compile(r'`([^`\n]+)`')),
|
||||||
|
('mention', re.compile(r'@\[([^\]]+)\]\(([^)\s]+)\)')),
|
||||||
('link', re.compile(r'\[([^\]]+)\]\(([^)\s]+)\)')),
|
('link', re.compile(r'\[([^\]]+)\]\(([^)\s]+)\)')),
|
||||||
('bold_star', re.compile(r'\*\*([^*\n]+)\*\*')),
|
('bold_star', re.compile(r'\*\*([^*\n]+)\*\*')),
|
||||||
('bold_under', re.compile(r'__([^_\n]+)__')),
|
('bold_under', re.compile(r'__([^_\n]+)__')),
|
||||||
@@ -347,6 +348,21 @@ def parse_rich_text(text: str) -> List[Dict[str, Any]]:
|
|||||||
|
|
||||||
if kind == 'code':
|
if kind == 'code':
|
||||||
spans.append(_rich_span(m.group(1), code=True))
|
spans.append(_rich_span(m.group(1), code=True))
|
||||||
|
elif kind == 'mention':
|
||||||
|
mention_title, mention_target = m.group(1), m.group(2)
|
||||||
|
page_id = extract_notion_id(mention_target)
|
||||||
|
if page_id:
|
||||||
|
spans.append({
|
||||||
|
"type": "mention",
|
||||||
|
"mention": {
|
||||||
|
"type": "page",
|
||||||
|
"page": {"id": format_id_with_dashes(page_id)},
|
||||||
|
},
|
||||||
|
"plain_text": mention_title,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# Invalid ID — degrade to plain text "@Title"
|
||||||
|
spans.append(_rich_span(f"@{mention_title}"))
|
||||||
elif kind == 'link':
|
elif kind == 'link':
|
||||||
link_text, link_url = m.group(1), m.group(2)
|
link_text, link_url = m.group(1), m.group(2)
|
||||||
if _is_absolute_url(link_url):
|
if _is_absolute_url(link_url):
|
||||||
|
|||||||
@@ -343,6 +343,41 @@ only one column here
|
|||||||
_assert("only one column here" in joined, "content preserved as paragraph")
|
_assert("only one column here" in joined, "content preserved as paragraph")
|
||||||
|
|
||||||
|
|
||||||
|
def test_mention_id():
|
||||||
|
"""@[Title](32-hex-id) produces a mention rich-text span with page reference."""
|
||||||
|
raw_id = "abcdef0123456789abcdef0123456789"
|
||||||
|
spans = parse_rich_text(f"see @[Roadmap]({raw_id}) for plans")
|
||||||
|
mention_spans = [s for s in spans if s.get("type") == "mention"]
|
||||||
|
_assert(len(mention_spans) == 1, "one mention span emitted")
|
||||||
|
m = mention_spans[0]
|
||||||
|
_assert(m["mention"]["type"] == "page", "mention type is page")
|
||||||
|
_assert(m["mention"]["page"]["id"].replace("-", "") == raw_id, "page id resolved")
|
||||||
|
_assert(m.get("plain_text") == "Roadmap", "plain_text is the title")
|
||||||
|
|
||||||
|
|
||||||
|
def test_mention_url():
|
||||||
|
"""@[Title](https://notion.so/Page-Title-id) extracts ID from URL."""
|
||||||
|
raw_id = "abcdef0123456789abcdef0123456789"
|
||||||
|
url = f"https://notion.so/My-Page-{raw_id}"
|
||||||
|
spans = parse_rich_text(f"check @[My Page]({url})")
|
||||||
|
mention_spans = [s for s in spans if s.get("type") == "mention"]
|
||||||
|
_assert(len(mention_spans) == 1, "one mention span emitted from URL form")
|
||||||
|
_assert(mention_spans[0]["mention"]["page"]["id"].replace("-", "") == raw_id,
|
||||||
|
"ID extracted from Notion URL")
|
||||||
|
|
||||||
|
|
||||||
|
def test_mention_invalid_falls_back():
|
||||||
|
"""A non-resolvable target degrades to plain text '@Title' with no link."""
|
||||||
|
spans = parse_rich_text("ping @[Bob](not-a-real-id) please")
|
||||||
|
mention_spans = [s for s in spans if s.get("type") == "mention"]
|
||||||
|
_assert(len(mention_spans) == 0, "no mention span for invalid id")
|
||||||
|
plain_text_joined = "".join(
|
||||||
|
s["text"]["content"] for s in spans if s.get("type") == "text"
|
||||||
|
)
|
||||||
|
_assert("@Bob" in plain_text_joined, "title rendered as plain '@Bob'")
|
||||||
|
_assert("not-a-real-id" not in plain_text_joined, "invalid id stripped from output")
|
||||||
|
|
||||||
|
|
||||||
def test_rich_text_anchor_link_becomes_bold():
|
def test_rich_text_anchor_link_becomes_bold():
|
||||||
"""TOC-style fragment links must not be sent as Notion link annotations."""
|
"""TOC-style fragment links must not be sent as Notion link annotations."""
|
||||||
spans = parse_rich_text("see [Section A](#section-a) below")
|
spans = parse_rich_text("see [Section A](#section-a) below")
|
||||||
@@ -405,6 +440,9 @@ def run_all():
|
|||||||
test_columns_two,
|
test_columns_two,
|
||||||
test_columns_with_blocks,
|
test_columns_with_blocks,
|
||||||
test_columns_single_degrades,
|
test_columns_single_degrades,
|
||||||
|
test_mention_id,
|
||||||
|
test_mention_url,
|
||||||
|
test_mention_invalid_falls_back,
|
||||||
test_rich_text_anchor_link_becomes_bold,
|
test_rich_text_anchor_link_becomes_bold,
|
||||||
test_rich_text_relative_link_becomes_plain,
|
test_rich_text_relative_link_becomes_plain,
|
||||||
test_rich_text_absolute_link_preserved,
|
test_rich_text_absolute_link_preserved,
|
||||||
|
|||||||
Reference in New Issue
Block a user