Skip to content

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.

LocationRole today
packages/ts/obsidian-editor/src/widgets.ts#TransclusionPillWidgetInert pill for non-image embeds; open affordance only
packages/ts/obsidian-editor/src/decorations.tsPlaces the pill; no content expansion
packages/ts/obsidian-editor/src/types.ts#EditorIndexProviderNo loadNote
packages/ts/obsidian-md/src/slice.ts#sliceEmbedRead mode’s heading/block-id slicing with its cycle and depth guards
packages/ts/obsidian-md/src/vue/context.ts#LoadedNoteThe 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#stubProviderStub 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.

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 routes provider.onInvalidate to requestRedraw) clears it, so the next rebuild reloads.
  • The pill remains the collapsed header and the whole fallback: loadNote absent, rejecting, or resolving null leaves exactly today’s pill and no widget.
  • Active line → widget and pill suppressed, raw … visible, as with every construct.
  1. Write the expansion cases in packages/ts/obsidian-editor/test/plugins/embeds.plugin.test.ts first, with a stub loadNote over a three-note corpus: Beta gets a block widget containing Beta’s rendered body; Beta contains only that section, byte-matching renderToHtmlSync(sliceEmbed(...)) as the oracle; Beta slices the block id.
  2. Add the guard cases: an embed inside Beta renders as pill markup, not a nested widget; Self inside the current note does not recurse; loadNote returning null or rejecting leaves the pill only.
  3. Add the cache cases: two rebuilds call loadNote once per target; a redrawEffect transaction makes the next rebuild call it again.
  4. Implement: loadNote on the types; async fill-and-redraw in the embeds plugin mirroring the math/mermaid widget pattern; expansion-disabled render options for the loaded content.
  5. Extend the Vue test: a provider with loadNote produces the widget under happy-dom.
  6. bun test, tsc --noEmit, moon run obsidian-editor:bench; confirm the swap-writer corpus is untouched (expansion never edits the document).
LocationKindChange
packages/ts/obsidian-editor/src/plugins/newCreated by T-4TE2-md-engine-plugin-contract; its embeds plugin module gains the expansion widget, cache and guards
packages/ts/obsidian-editor/src/types.tsmodifyloadNote? on the provider
packages/ts/obsidian-editor/test/plugins/embeds.plugin.test.tsnewCreated by T-4TE2-md-engine-plugin-contract’s test split; gains the expansion, guard and cache cases
packages/ts/obsidian-editor/test/vue.test.tsmodifyExpansion under a loadNote provider
  • AC-1: bun test packages/ts/obsidian-editor passes, including the expansion cases.
  • AC-2: with a stub loadNote, Beta’s widget markup equals renderToHtmlSync(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: loadNote absent, rejecting, or resolving null produces exactly the pill decoration set that exists before this task (asserted by comparing decoration snapshots).
  • AC-5: two rebuilds call loadNote once per distinct target; after a transaction carrying redrawEffect the next rebuild calls it again.
  • AC-6: bun test packages/ts/obsidian-editor/test/swap-writer.test.ts passes unchanged — expansion never modifies the document.
  • AC-7: moon run obsidian-editor:bench stays within 1.5× of the recorded baseline.
  • 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 loadNote implementation (T-3DFJ-notes-adopts-markdown-ui wires it during adoption).

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.

T-RCWI-md-obsidian-md-sync-helpers


← Back to Tasks