Skip to content

Add workspace-wide wiki-link resolution, transclusion, and embeds#3

Merged
samrobn merged 10 commits into
mainfrom
feat/wikilink-resolution
May 28, 2026
Merged

Add workspace-wide wiki-link resolution, transclusion, and embeds#3
samrobn merged 10 commits into
mainfrom
feat/wikilink-resolution

Conversation

@samrobn
Copy link
Copy Markdown
Owner

@samrobn samrobn commented May 28, 2026

Adds Obsidian-style wiki-link resolution, note transclusion, and image embeds to the markdown preview, plus the fixes from a full code-review pass.

Feature

  • Wiki-links resolved against a workspace-wide .md index built on activate and maintained by per-root FileSystemWatchers: [[name]], [[name#heading]], [[name^block]], [[name|alias]], [[#heading]] (same-document), and folder-qualified [[folder/name]].
  • Note transclusion ![[note]] / ![[note#heading]] (recursive render, depth-2 cycle cap) and image embeds ![[image.png]] / ![[image.png|width]] with a broken-image fallback.
  • Four markdownPreviewStyles.wikilinks.* settings (zero-config working defaults); extraIndexRoots supports ~ expansion.
  • Bespoke parser/resolver, no runtime deps, unit-testable in plain Node.

Click behaviour

A resolved wiki-link emits a path relative to the previewed document, so clicking routes through VS Code's openLink channel (native in-preview navigation, no OS prompt) — identical to a plain [text](relative.md) link. The document path is read from env.currentDocument (a vscode.Uri in 1.122), with env.resourceProvider.resource as the fallback that survives the incremental live-edit render path.

Review fixes (in this branch)

  • Embed/href fallbacks emit clickable hrefs (were bare absolute paths VS Code can't navigate); resolvable-but-not-transcluded embeds keep their href; alias display; self-link scrolls in place.
  • Frontmatter Properties wiki-links now resolve and honour aliases.
  • Source-mapping core rules (mps_blank_lines, mps_block_anchors) are gated inside transclusion so embedded notes don't inject duplicate line numbers / block-anchor ids.
  • Unified heading slugifier with GitHub parity (keeps Unicode letters); canonical block-id charset; pure-fragment links.
  • Concurrent index-rebuild guard (generation token) prevents duplicate/leaked watchers.
  • Fixed two regressions caught by adversarial review + live examples: bare self-link rendering as an inert span, and pure-fragment links producing a doubled anchor.

Docs / tests

  • example.md wiki-link section reworked into a tight list of every supported format, each with a live example.
  • CLAUDE.md documents the hard-won VS Code preview internals (env shape, embed-depth gating, GitHub-slug parity).
  • 129 unit tests (was 102), all passing. CI runs node test/test.js.

Deferred to follow-up

embedMaxBytes boundary, vscode://file Windows path separator, drive-letter safeHref rejection, and context.subscriptions growth across rebuilds — all minor / Windows-only (this is a macOS-only sideload).

samrobn added 10 commits May 28, 2026 20:18
Extends the markdown preview with Obsidian-style wikilinks resolved
against a workspace-wide `.md` index, note transclusion, and image
embeds:

- `[[name]]`, `[[name#heading]]`, `[[name^block]]`, `[[name|alias]]`
  resolved workspace-wide via an index built on activate and maintained
  by per-root FileSystemWatchers. Fragment-before-pipe canonical form.
- `![[note]]` transclusion (recursive md.render with a depth-2 cycle
  cap), `![[image.png]]` / `![[image.png|width]]` embeds with a
  broken-image fallback.
- Four `markdownPreviewStyles.wikilinks.*` settings (enabled,
  extraIndexRoots, embedNotes, embedMaxBytes) defaulting to a
  zero-config working case. extraIndexRoots supports `~` expansion.
- Bespoke parser/resolver (no runtime deps), unit-testable in plain
  Node via the `__setWikiStateForTest` seam.

Click-behaviour fix: a resolved wikilink emits a path relative to the
previewed document so clicking routes through VS Code's `openLink`
channel (native in-preview navigation, no OS prompt) rather than a
`vscode://file/...` URI. The previewed document's path is read from
`env.currentDocument` (a vscode.Uri in 1.122 - `.fsPath`, not `.uri`)
with `env.resourceProvider.resource` as the fallback that survives the
incremental live-edit render path, where currentDocument is undefined.

108 unit tests; CLAUDE.md documents the hard-won VS Code preview
internals.
The mps_embed_note degrade paths (cycle cap, fragment-miss, read error)
emitted href="${resolvedPath}" - a bare absolute disk path - which the
main renderer's own comment documents as broken: VS Code concatenates a
schemeless absolute href onto the preview directory and ENOENTs. Route
all three through the same href construction the wikilink renderer uses.

Extracts two helpers from the mps_wikilink renderer so every emit site
shares one implementation:

- docPathFromEnv(env): the currentDocument/resourceProvider chain.
- buildResolvedHref(resolvedPath, fragment, docPath): relative /
  vscode://file / bare-fragment forms.

Also fixed in the same code:

- A resolvable target that simply wasn't transcluded (embedNotes off, or
  over the size cap) now resolves in the degrade branch and keeps a
  working href, instead of falling back to a dead bare-name link.
- Embed fallback display text now comes from the parsed target, so an
  alias shows cleanly ("Nice Alias") rather than the literal "foo|Nice
  Alias".
- A note that wiki-links to itself emits just the fragment, so the click
  scrolls in place instead of reloading the document.
- Removed the dead meta.embedCycle branch in the wikilink renderer
  (nothing ever set it; the cycle class is applied in mps_embed_note).

114 unit tests.
renderText (the Properties-table value renderer) emitted frontmatter
[[wiki-links]] without resolving them or honouring alias/fragment
syntax: `parent: [[meeting|Weekly sync]]` rendered as a dead
href="meeting|Weekly sync" with the literal pipe as display text, while
the same link in the document body resolved to a real path and showed
"Weekly sync".

renderText now parses + resolves via the same parseWikilinkTarget /
resolveWikilinkTarget / buildResolvedHref path the body renderer uses.
docPath is threaded through renderProperties -> renderValue ->
renderText from the mps_frontmatter core rule's env, so resolved
frontmatter links get a document-relative href where available and the
vscode://file form otherwise.

The "treated as a string" frontmatter rule is unaffected - that governs
value-type PARSING (not splitting [[x]] into an array), not rendering.

Also consolidated the scattered per-block extension.js requires in the
test file into one top-level import.

116 unit tests.
The recursive md.render(sliced) used for note transclusion re-ran the
full core-rule pipeline on the embedded slice. Two rules emit
host-document-scoped output that must not run inside an embed:

- mps_blank_lines stamped data-line / data-mps-line numbered from the
  SLICE's line 0 and injected blank-line placeholders, colliding with
  the host document's line numbers and misleading VS Code's active-line
  tracker / double-click-to-jump. Now skipped entirely when
  env.mpsEmbedDepth is set (embedded content carries no gutter).
- mps_block_anchors emitted id="mps-block-<id>" for a ^block marker in
  the embedded note, producing a duplicate id (invalid HTML, ambiguous
  scroll target) when the host or a standalone open of the note also
  carries it. Now strips the marker for clean text but suppresses the id
  inside an embed.

(mps_callouts still runs inside embeds - callouts are content, not
source-mapping. mps_frontmatter is a no-op there because the slice has
already had its frontmatter stripped.)

118 unit tests.
Three related fragment/slug fixes:

- Heading slug: the lowercase/strip/hyphenate algorithm was copy-pasted
  three times (sliceToFragment x2, fragmentToAnchor) and diverged from
  VS Code's GithubSlugifier - it stripped Unicode letters (`Café` ->
  `caf`) and collapsed consecutive whitespace, so `[[note#Café]]`
  emitted an anchor that never matched the rendered heading id. Extracted
  one slugifyHeading helper that reproduces the GitHub slugifier's output
  (keeps Unicode letters/numbers/marks, whitespace -> '-' per character)
  without vendoring its ~2KB Unicode code-point table.

- Block-id charset: parseWikilinkTarget accepted any chars after `^`
  ([^#^]+) while the slice strip and the mps-block anchor only handle
  [A-Za-z0-9_-]. `[[note^a b]]` half-parsed into a `#mps-block-a b`
  anchor that could never match. A shared BLOCK_ID_RE now gates the `^`
  fragment in the parser, so an invalid id is left in the name instead.

- Pure fragments: `[[#heading]]` / `[[^block]]` parsed the marker as the
  name (fragment null), so the same-document link Obsidian supports
  resolved to nothing. The parser now allows an empty name; the renderer
  emits a bare same-document fragment anchor.

(Per-segment href encoding - the `?`/`#`-in-filename fix - already
landed with the embed/href helper extraction.)

125 unit tests.
rebuildWorkspaceIndex awaits findFiles per root, so a config change
firing during the initial build (or a fast succession of changes) could
interleave two rebuilds: the older one resumed after its await and kept
adding to the now-shared index and pushing watchers, leaving duplicate
live FileSystemWatchers (each file event fires twice) and orphaned
watchers that the next rebuild's teardown missed.

Adds a monotonic generation counter: each rebuild captures the
generation at entry and, after every await, bails if a newer rebuild has
started. Watchers accumulate in a local array and are committed to the
shared _activeWatchers / context.subscriptions only once the rebuild
wins the race; a superseded rebuild disposes anything it created instead
of leaking it. This also bounds context.subscriptions growth - only the
winner's watchers are registered.

Adds an async test harness (testAsync/runAsyncTests) and a test that
interleaves two rebuilds via a gated mock findFiles, asserting the
superseded rebuild leaves exactly one live watcher and no orphan.

126 unit tests.
buildResolvedHref returned '' for a self-link with no fragment
([[current]] inside current.md), because fragmentToAnchor(null) is ''
and the self-link branch returned the anchor unconditionally. The
mps_wikilink renderer treats an empty href as the rejected-dangerous-
scheme case and emits an inert <span>, so a note's bare link to itself
went from a clickable link to a dead span - a regression from the
pre-fix renderer, which emitted href="current.md".

The self-link short-circuit now fires only when there's actually a
fragment (scroll in place); a bare self-link falls through to the
relative path (its own basename - a normal reload link). Adds the
missing no-fragment self-link test that the original change's test
blind-spotted.

127 unit tests.
Two hard-won facts from the review-fix pass:

- Note transclusion's recursive md.render re-runs the full core-rule
  pipeline; source-mapping rules (mps_blank_lines, mps_block_anchors)
  must gate on env.mpsEmbedDepth or they inject host-colliding line
  numbers and duplicate block-anchor ids. Any new source-mapping rule
  needs the same gate.
- Heading-anchor hrefs must match VS Code's GitHub slugifier (keeps
  Unicode letters, replaces whitespace per-character). The slugifyHeading
  helper reproduces it; don't simplify back to an ASCII-only class.
An unresolved pure-fragment link ([[#heading]] / [[^block]]) rendered a
doubled, malformed href and the raw `#frag` as display text:
`[[#Lists]]` -> href="#Lists#lists", display "#Lists". The no-resolve
href branch used `(parsed.name || content)`, which falls back to the raw
token content (already the fragment) and then appended fragmentToAnchor
again; the display used basenameWithoutExt of the same raw content.

The renderer now branches on the parsed shape: named link -> raw name +
anchor; pure fragment -> the anchor alone (a same-document link);
otherwise the raw content. Display for a pure fragment is the fragment's
own text (heading text / block id), not the `#frag` string.

Found by adding every wikilink format as a live example to example.md -
the unresolved-pure-fragment path through the full renderer wasn't
covered by any test. Adds tests for both heading and block pure
fragments.

129 unit tests.
Replace the narrow two-row "Link format comparison" table (which mixed a
plain markdown link in with wikilinks) with a tight list of every
supported [[...]] / ![[...]] form - basic, heading/block fragment,
alias, same-document fragment, folder-qualified, note transclusion,
image embed - each with a live example that resolves against the
fixtures. Folds the standalone "Image embeds" intro into the list (the
block demos stay, since images must render at block size).
@samrobn samrobn merged commit 578e405 into main May 28, 2026
1 check passed
@samrobn samrobn deleted the feat/wikilink-resolution branch May 28, 2026 23:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant