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:
2026-04-27 11:33:10 +09:00
parent 0421a65327
commit 328de7d052
2 changed files with 56 additions and 2 deletions

View File

@@ -343,6 +343,41 @@ only one column here
_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():
"""TOC-style fragment links must not be sent as Notion link annotations."""
spans = parse_rich_text("see [Section A](#section-a) below")
@@ -405,6 +440,9 @@ def run_all():
test_columns_two,
test_columns_with_blocks,
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_relative_link_becomes_plain,
test_rich_text_absolute_link_preserved,