T-E99V-md-engine-transclusion-expansion
State: closed/done · Impact: high · Complexity: large
A non-image Note renders as an inert pill in live mode, so the one Obsidian feature that
makes transclusion useful — seeing the embedded content while editing — is missing. This task
adds inline expansion: when the provider can load notes, the embed renders the target’s content
as a block widget below the line, depth 1, cached.
| Location | Role today |
|---|---|
packages/ts/obsidian-editor/src/widgets.ts#TransclusionPillWidget | Inert pill for non-image embeds; open affordance only |
packages/ts/obsidian-editor/src/decorations.ts | Places the pill; no content expansion |
packages/ts/obsidian-editor/src/types.ts#EditorIndexProvider | No loadNote |
packages/ts/obsidian-md/src/slice.ts#sliceEmbed | Read mode’s heading/block-id slicing with its cycle and depth guards |
packages/ts/obsidian-md/src/vue/context.ts#LoadedNote | The load contract; T-RCWI-md-obsidian-md-sync-helpers moves the type to core so the engine’s Vue-free entry can import it |
packages/ts/obsidian-editor/test/helpers.ts#stubProvider | Stub provider the expansion tests extend with loadNote |
The embeds plugin module comes from T-4TE2-md-engine-plugin-contract’s decoration split;
requestRedraw and the bench from T-0GV4-md-engine-line-endings-corpus.
Proposed
Section titled “Proposed”EditorIndexProvider gains loadNote?(path: string): Promise<LoadedNote | null> (LoadedNote
imported from @sksizer/obsidian-md core after T-RCWI-md-obsidian-md-sync-helpers).
The embeds plugin gains expansion. When loadNote is present, a non-image Note,
Note or Note renders, in addition to the pill, a block widget below
the line whose DOM is renderToHtmlSync(sliceEmbed(body, fragment)) of the loaded note:
- Depth 1: the expansion is rendered with embed expansion disabled, so embeds inside the loaded
content appear as read-mode pills, never nested widgets. Read mode’s cycle and depth guards
are reused via
sliceEmbed, so a self-embed cannot loop. - Cache per resolved path plus fragment. A rebuild hits the cache; a transaction carrying
redrawEffect(the host’s invalidation signal — chrome routesprovider.onInvalidatetorequestRedraw) clears it, so the next rebuild reloads. - The pill remains the collapsed header and the whole fallback:
loadNoteabsent, rejecting, or resolvingnullleaves exactly today’s pill and no widget. - Active line → widget and pill suppressed, raw
…visible, as with every construct.
Approach
Section titled “Approach”- Write the expansion cases in
packages/ts/obsidian-editor/test/plugins/embeds.plugin.test.tsfirst, with a stubloadNoteover a three-note corpus:Betagets a block widget containing Beta’s rendered body;Betacontains only that section, byte-matchingrenderToHtmlSync(sliceEmbed(...))as the oracle;Betaslices the block id. - Add the guard cases: an embed inside Beta renders as pill markup, not a nested widget;
Selfinside the current note does not recurse;loadNotereturningnullor rejecting leaves the pill only. - Add the cache cases: two rebuilds call
loadNoteonce per target; aredrawEffecttransaction makes the next rebuild call it again. - Implement:
loadNoteon the types; async fill-and-redraw in the embeds plugin mirroring the math/mermaid widget pattern; expansion-disabled render options for the loaded content. - Extend the Vue test: a provider with
loadNoteproduces the widget under happy-dom. bun test,tsc --noEmit,moon run obsidian-editor:bench; confirm the swap-writer corpus is untouched (expansion never edits the document).
Files to touch
Section titled “Files to touch”| Location | Kind | Change |
|---|---|---|
packages/ts/obsidian-editor/src/plugins/ | new | Created by T-4TE2-md-engine-plugin-contract; its embeds plugin module gains the expansion widget, cache and guards |
packages/ts/obsidian-editor/src/types.ts | modify | loadNote? on the provider |
packages/ts/obsidian-editor/test/plugins/embeds.plugin.test.ts | new | Created by T-4TE2-md-engine-plugin-contract’s test split; gains the expansion, guard and cache cases |
packages/ts/obsidian-editor/test/vue.test.ts | modify | Expansion under a loadNote provider |
Acceptance criteria
Section titled “Acceptance criteria”- AC-1:
bun test packages/ts/obsidian-editorpasses, including the expansion cases. - AC-2: with a stub
loadNote,Beta’s widget markup equalsrenderToHtmlSync(sliceEmbed(...))of Beta’s body for that heading — the read-mode oracle. - AC-3: an embed inside the expanded content renders as a pill, not a widget (depth 1), and a self-referential embed terminates without recursion.
- AC-4:
loadNoteabsent, rejecting, or resolvingnullproduces exactly the pill decoration set that exists before this task (asserted by comparing decoration snapshots). - AC-5: two rebuilds call
loadNoteonce per distinct target; after a transaction carryingredrawEffectthe next rebuild calls it again. - AC-6:
bun test packages/ts/obsidian-editor/test/swap-writer.test.tspasses unchanged — expansion never modifies the document. - AC-7:
moon run obsidian-editor:benchstays within 1.5× of the recorded baseline.
Out of scope
Section titled “Out of scope”- Editing inside the expansion; the widget is read-only rendered content and the pill’s open affordance remains the way in.
- Hover previews (T-RJVA-md-ui-find-outline-hover-lifts renders those with
MdViewer). - Image embeds; the existing image widget path is untouched.
- The notes provider’s
loadNoteimplementation (T-3DFJ-notes-adopts-markdown-ui wires it during adoption).
Dependencies
Section titled “Dependencies”- T-RCWI-md-obsidian-md-sync-helpers for
renderToHtmlSyncandLoadedNotein core. - T-4TE2-md-engine-plugin-contract for the embeds plugin module;
T-0GV4-md-engine-line-endings-corpus for
redrawEffect/requestRedrawand the bench. - D-7VMX: one parse per plane — the expansion renders through
obsidian-md’s pipeline, never a second editor parse.
Discovery context
Section titled “Discovery context”Member of milestone M-931L; engine step E12 of decision D-K3EM-markdown-editor-chrome-and-plugin-environment. Inline expansion is in scope by explicit user decision recorded in the plan.
Depends on
Section titled “Depends on”T-RCWI-md-obsidian-md-sync-helpers