Editor: block ids on quotes and callouts need one written form that Obsidian and both converters read #90

Open
opened 2026-09-25 14:02:04 +00:00 by kayg · 1 comment
Owner

Problem

A block id on a blockquote or a callout has three different forms in calternal today, and the two Markdown converters do not agree on them.

  1. TS converter (packages/editor/src/markdown.ts, used by the fallback editor and the tests). It reads the Obsidian "following line" form

    > Quoted text
    ^id
    

    as the quote's own id (appendContainerAnchor sets blockAnchor / blockAnchorRunCount on the container). It writes the same compact form back (containerWithoutAnchor). Under CommonMark the bare ^id line is a lazy continuation of the quote paragraph.

  2. Rust converter (crates/calternal-collab/src/markdown.rs, used by every live note through the Yrs room). The blockquote and callout readers stop at the first line that does not start with > and always set blockAnchor: null. The ^id line becomes a separate paragraph. The writer joins blocks with a blank line, so a save changes the file to > Quoted text\n\n^id, and the live editor shows ^id as a paragraph of its own.

  3. Copy link to block (apps/web/src/lib/notes/anchors.ts, ensureAnchorAt). For a quote or callout it finds the inner paragraph and appends ^id to its text, so the file gets > Quoted text ^id. findAnchor then finds the inner paragraph, not the quote.

Obsidian's help (https://obsidian.md/help/links) says a structured block (list, quote, callout, table) takes its id "on a separate line, with a blank line before and after", but its own example puts the id directly under the quote. It is not verified which forms Obsidian really resolves.

Impact: interoperability and byte stability (a note opened and saved in calternal can change bytes the user did not touch). No text is lost.

Reproduce

  1. Put > Quoted text\n^abc123\n\nNext paragraph\n in Notes/test.md.
  2. Open it in the web editor (live mode) and type one character in "Next paragraph".
  3. The file now has a blank line between the quote and ^abc123, and the editor showed ^abc123 as its own paragraph.
  4. In a new quote, use Copy link on the quote: the file gets > text ^xxxxxx (a third form).

Relevant files

  • packages/editor/src/markdown.ts (appendContainerAnchor, containerWithoutAnchor, lastLineIsBlockId)
  • packages/editor/src/anchor.ts (block id grammar, blockIdFromNode)
  • crates/calternal-collab/src/markdown.rs (blockquote and callout reader and writer)
  • apps/web/src/lib/notes/anchors.ts (ensureAnchorAt, findAnchor)
  • Deep-link grammar /n/<id>#^<block-id>: DESIGN §33.

Decisions already made

  • ^block-ids are a file-level feature, never hidden editor state (DESIGN §17, §19). Keep the calternal.js Markdown dialect (DESIGN §19).
  • The TS and Rust converters must produce identical bytes: #87 is porting the TS reader and writer to Rust. Do this issue on top of #87 (or together with it), not as a separate Rust special case.
  • Always read every form that exists in user files (compact, blank-line, inline > text ^id); write one form only.

Work

  1. Verify in real Obsidian (the owner has it) which forms resolve [[note#^id]] to the whole quote and the whole callout: compact, blank line before, inline on the last quote line. Also check that a blank line after the id keeps the next block separate. Record the result in the module doc of anchor.ts.
  2. Pick the write form Obsidian resolves; keep reading all three.
  3. Make ensureAnchorAt give a quote or callout a container id in that form (not an inline id on the inner paragraph), and make findAnchor resolve it to the container.

Acceptance criteria

  • A shared vector (contracts/vectors/markdown or the editor vector set) for each read form and the one write form, passing in both converters with identical bytes.
  • Opening and saving a note that has any of the three forms, without touching the quote, keeps the file bytes (or changes only to the chosen form, if the owner accepts that).
  • Copy link on a quote and a callout gives /n/<id>#^<block-id>; opening it scrolls to and flashes the whole quote.
  • Gates: cargo test -p calternal-collab, bun run check, bun run test in apps/web and packages/editor, quoted verbatim.

Carried over from kayg/calternal.js#192

## Problem A block id on a blockquote or a callout has three different forms in calternal today, and the two Markdown converters do not agree on them. 1. **TS converter** (`packages/editor/src/markdown.ts`, used by the fallback editor and the tests). It reads the Obsidian "following line" form ``` > Quoted text ^id ``` as the quote's own id (`appendContainerAnchor` sets `blockAnchor` / `blockAnchorRunCount` on the container). It writes the same compact form back (`containerWithoutAnchor`). Under CommonMark the bare `^id` line is a lazy continuation of the quote paragraph. 2. **Rust converter** (`crates/calternal-collab/src/markdown.rs`, used by every live note through the Yrs room). The blockquote and callout readers stop at the first line that does not start with `>` and always set `blockAnchor: null`. The `^id` line becomes a separate paragraph. The writer joins blocks with a blank line, so a save changes the file to `> Quoted text\n\n^id`, and the live editor shows `^id` as a paragraph of its own. 3. **Copy link to block** (`apps/web/src/lib/notes/anchors.ts`, `ensureAnchorAt`). For a quote or callout it finds the inner paragraph and appends ` ^id` to its text, so the file gets `> Quoted text ^id`. `findAnchor` then finds the inner paragraph, not the quote. Obsidian's help (https://obsidian.md/help/links) says a structured block (list, quote, callout, table) takes its id "on a separate line, with a blank line before and after", but its own example puts the id directly under the quote. It is not verified which forms Obsidian really resolves. Impact: interoperability and byte stability (a note opened and saved in calternal can change bytes the user did not touch). No text is lost. ## Reproduce 1. Put `> Quoted text\n^abc123\n\nNext paragraph\n` in `Notes/test.md`. 2. Open it in the web editor (live mode) and type one character in "Next paragraph". 3. The file now has a blank line between the quote and `^abc123`, and the editor showed `^abc123` as its own paragraph. 4. In a new quote, use Copy link on the quote: the file gets `> text ^xxxxxx` (a third form). ## Relevant files - `packages/editor/src/markdown.ts` (`appendContainerAnchor`, `containerWithoutAnchor`, `lastLineIsBlockId`) - `packages/editor/src/anchor.ts` (block id grammar, `blockIdFromNode`) - `crates/calternal-collab/src/markdown.rs` (blockquote and callout reader and writer) - `apps/web/src/lib/notes/anchors.ts` (`ensureAnchorAt`, `findAnchor`) - Deep-link grammar `/n/<id>#^<block-id>`: DESIGN §33. ## Decisions already made - `^block-ids` are a file-level feature, never hidden editor state (DESIGN §17, §19). Keep the calternal.js Markdown dialect (DESIGN §19). - The TS and Rust converters must produce identical bytes: #87 is porting the TS reader and writer to Rust. Do this issue on top of #87 (or together with it), not as a separate Rust special case. - Always read every form that exists in user files (compact, blank-line, inline `> text ^id`); write one form only. ## Work 1. Verify in real Obsidian (the owner has it) which forms resolve `[[note#^id]]` to the whole quote and the whole callout: compact, blank line before, inline on the last quote line. Also check that a blank line after the id keeps the next block separate. Record the result in the module doc of `anchor.ts`. 2. Pick the write form Obsidian resolves; keep reading all three. 3. Make `ensureAnchorAt` give a quote or callout a container id in that form (not an inline id on the inner paragraph), and make `findAnchor` resolve it to the container. ## Acceptance criteria - A shared vector (`contracts/vectors/markdown` or the editor vector set) for each read form and the one write form, passing in both converters with identical bytes. - Opening and saving a note that has any of the three forms, without touching the quote, keeps the file bytes (or changes only to the chosen form, if the owner accepts that). - Copy link on a quote and a callout gives `/n/<id>#^<block-id>`; opening it scrolls to and flashes the whole quote. - Gates: `cargo test -p calternal-collab`, `bun run check`, `bun run test` in `apps/web` and `packages/editor`, quoted verbatim. Carried over from kayg/calternal.js#192
Author
Owner

I rechecked origin/dev. The original TS/Rust mismatch is addressed in packages/editor/src/markdown.ts and crates/calternal-collab/src/markdown.rs: both now attach a following-line anchor to quote/callout container metadata, and both serializers emit that container anchor after the block. contracts/vectors/markdown/README.md records the own-line form. The checked-in shared vectors and converter tests I found cover unanchored quote/callout blocks, but not anchored quote/callout round trips; this issue’s real Obsidian resolution check is also not recorded. Keeping #90 open for those two pieces of evidence.

I rechecked origin/dev. The original TS/Rust mismatch is addressed in packages/editor/src/markdown.ts and crates/calternal-collab/src/markdown.rs: both now attach a following-line anchor to quote/callout container metadata, and both serializers emit that container anchor after the block. contracts/vectors/markdown/README.md records the own-line form. The checked-in shared vectors and converter tests I found cover unanchored quote/callout blocks, but not anchored quote/callout round trips; this issue’s real Obsidian resolution check is also not recorded. Keeping #90 open for those two pieces of evidence.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal#90
No description provided.