overengineeringstudio / overengineeringstudio/effect-utils

notion-md: model page references (child anchor vs inline mention vs link-to-page) — inline <page> silently corrupts

Open
#744 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

area:notion origin:agent type:bug type:feature
Dominant language
TypeScript
Stars
82
Forks
2
Avg merge
1d 8h
Merged PRs (30d)
121

Description

Problem

notion-md exposes a single construct for page references:

<page url="...">label</page>

This is the serialization of a Notion child_page block — a block-level anchor for a page that is an actual child of the current page. It is currently overloaded to stand in for two other, semantically distinct concepts, and both of those fail. The worst failure is silent corruption: an author who writes a <page> inline in a sentence (a reasonable expectation) gets garbage on round-trip, with no error.

Repro

Verified directly against Notion's enhanced-markdown endpoint via ntn (notion-cli 0.15.1), not just through notion-md. A page body is pushed and then pulled back. Page IDs are sanitized to <PAGE_ID> placeholders below.

Pushed body (one paragraph with an inline <page>, one block-level <page> on its own line, and one plain markdown link):

This paragraph has an INLINE ref: <page url="https://www.notion.so/<PAGE_ID>">Notionclaw</page> in the middle of a sentence.

<page url="https://www.notion.so/<PAGE_ID>">Notionclaw</page>

A plain markdown link: [Notionclaw](https://www.notion.so/<PAGE_ID>) in a sentence.

Pulled back, the three forms behave very differently:

1. Inline <page> (used inside prose) — silently CORRUPTS. The paragraph round-trips as escaped garbage:

... INLINE ref: \<page url="[<PAGE_ID_URL>"\>Notionclaw\</page\>](<PAGE_ID_URL>">Notionclaw</page>) in the middle ...

The tag is half-escaped, half-swallowed into a broken markdown link. This is endpoint-level behavior (the enhanced-markdown endpoint does not understand an inline <page>), and notion-md currently passes it straight through — so the corruption surfaces to the author.

2. Block-level <page> to a NON-child page — degrades to an alias. When the referenced page is not a child of the current page, Notion does not produce a clean child anchor; it produces an alias, which round-trips as:

<unknown url="..." alt="alias"/>

3. Plain markdown link [Notionclaw](url) — round-trips CLEANLY as a normal inline link. This is the only one of the three that survives intact today.

The modeling gap

Three distinct concepts are conflated under one <page> construct. Each maps to a different Notion primitive:

Concept Where it appears Notion primitive Status in notion-md
child-page anchor block, child page only child_page block modeled (this is what <page> means)
inline page reference inline, within prose inline mention (type=page) rich-text, or a plain text link with href corrupts
link-to-page / alias block, to a non-child page link_to_page block degrades (<unknown ... alt="alias"/>)

notion-md models only the first. The other two corrupt or degrade. The core defect is the silent corruption of the inline case — there is no validation that rejects a block-level construct used in inline position, so the author gets garbage instead of an error.

Design options

A — Distinct typed constructs

Model each concept explicitly in the .nmd / wire form:

  • keep block <page> for child anchors (child_page),
  • add an inline construct that maps to Notion mention (type=page) rich-text,
  • add a link_to_page block construct for block-level references to non-child pages.

Tradeoffs: most faithful to Notion's actual data model; round-trips all three cleanly; most implementation work (three constructs, three mappers, plus inline rich-text mention support in both directions).

B — Symbolic refs + context-aware resolver (recommended, long-term)

Authors write one symbolic reference (e.g. [[slug]] or ./file.nmd). A resolver chooses the Notion representation by position:

  • inline (within a paragraph) → mention (or hyperlink) rich-text,
  • block on its own line (e.g. in an index/page-list) → child_page / link_to_page block,

and reverses the mapping on pull.

Tradeoffs: hides Notion-representation complexity from authors entirely; a single authoring affordance for all reference kinds; composes directly with the in-flight native subtree-sync design, which already needs a slug ↔ page_id map for cross-reference resolution. Most valuable but depends on the resolver/identity-map work landing.

Open sub-question: inline references as live mentions (auto-updating title + icon, more native, but requires bidirectional mention support) vs plain hyperlinks (simpler, already round-trips cleanly today). Could start with hyperlinks and upgrade to mentions later.

C — Plain markdown links only

Drop special inline constructs; use [label](page-url) for all references.

Tradeoffs: simplest, and already round-trips cleanly; but loses live mentions, and is only a partial answer — an index/page-list still needs block-level child anchors, so <page> (or link_to_page) can't be fully dropped.

D — Defensive detect + fail/normalize (recommended, immediate safety net)

Independent of the above: notion-md must never silently corrupt. During canonicalize/validate, detect a block-level <page> used in inline context and either:

  • error with a clear message (e.g. "<page> is block-level; use a link/mention for an inline page reference"), or
  • auto-normalize it to a hyperlink.

Tradeoffs: cheap; kills the silent-garbage class immediately; does not by itself add inline mention support, but stops the data loss now.

Recommendation
  • D now — stop the silent corruption immediately with detect-and-fail/normalize.
  • B long-term — symbolic refs + context-aware resolver, composing with native subtree sync. Optionally fold in A's mention support as a sub-part once bidirectional mention round-tripping is proven.

Acceptance criteria

  • An inline page reference authored in prose round-trips without corruption (no escaped \<page\> garbage, no broken markdown links).
  • A block-level <page> used in inline position is rejected with a clear error or normalized to a valid inline reference — never silently corrupted.
  • A block-level reference to a non-child page resolves to a defined, round-trip-stable representation (clean link_to_page or an explicit, documented form) rather than <unknown ... alt="alias"/>.
  • The three concepts (child-page anchor, inline reference, link-to-page) are distinguishable in the model, with documented mappings to their Notion primitives.
  • Fake/E2E coverage proves the inline-reference round-trip and the block-misuse rejection/normalization.

Relation to other work

  • Native bidirectional subtree sync (managed-workspace watch + materialization, #680 / #682, under epic #698): there is an in-flight design for syncing a local directory tree ↔ a Notion subtree with cross-reference resolution. This page-reference modeling is a dependency / sub-part of that resolver — the slug ↔ page_id identity map needed for option B is the same map subtree sync needs to rewrite cross-references between local files and Notion pages. Refs #698, Refs #680.
  • Pluggable sync targets over a shared WorkspacePort (#700): the reference resolver belongs in the shared sync spine so every target (md, git, FUSE) inherits consistent cross-page reference handling rather than reimplementing it.
  • Shared Notion domain / transport (#734): the typed reference constructs (child-page anchor / mention / link-to-page) are candidates for the shared block/rich-text domain rather than being modeled ad hoc per package.
  • Related but distinct: there is a separate blockquote / blank-line-merge round-trip bug in notion-md. It is a different class of round-trip defect and should be tracked separately, not folded into this issue.
Posted on behalf of @schickling
field value
agent_name 🦤 cl2-ibis
agent_session_id 49ccf2ed-0881-48a7-b504-fa512767a52a
agent_tool Claude Code
agent_tool_version 2.1.160
agent_runtime Claude Code 2.1.160
agent_model claude-opus-4-8
worktree dotfiles/schickling/2026-05-27-notionclaw
machine dev3
tooling_profile dotfiles@unknown-dirty

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in notion-md's canonicalize/validate path and inspect the existing child_page handling, then review the fake/E2E coverage for round trips. Compare the inline, child-page, and non-child page-reference cases against the acceptance criteria; done means no silent corruption, clear rejection or normalization, and stable representations for all three concepts.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
backend-api-design
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Needs clarification
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.