diff --git a/CLAUDE.md b/CLAUDE.md index 1e4374e..a65c8fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,10 +4,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this is -A local-only, sideloaded VS Code extension. Two surfaces: +A local-only, sideloaded VS Code extension. Three surfaces: 1. `style.css` injected via `markdown.previewStyles` - tweaks every markdown preview. -2. `extension.js` registered via `markdown.markdownItPlugins` - extends markdown-it with core rules and an inline rule (Properties table from YAML frontmatter, line-number gutter, blank-line placeholders, wiki-link styling). +2. `extension.js` registered via `markdown.markdownItPlugins` - extends markdown-it with core rules (`mps_block_anchors`, `mps_callouts`, `mps_blank_lines`, `mps_frontmatter`) and inline rules (`mps_embed`, `mps_wikilink`) for the Properties table, line-number gutter, blank-line placeholders, callouts, Obsidian-style wikilinks (workspace-wide resolution with alias / `#heading` / `^block` fragments), and `![[name]]` note transclusion. +3. `preview.js` registered via `markdown.previewScripts` - runs in the webview for per-element line-number gutter alignment and broken-image fallback. + +`activate(context)` also builds a workspace `.md` index (used by the wikilink resolver) and registers `FileSystemWatcher`s per indexed root. Never published. Installed by symlinking the repo into `~/.vscode/extensions/local.markdown-preview-styles-/`. Install / re-symlink steps are in `README.md`. @@ -21,10 +24,14 @@ node test/visual/render.js check # + computed-style assertions via agent-browse No build step at the top level. No `node_modules` in repo root. No publish step. Exits non-zero on test failure - safe to wire into a pre-commit hook. -Unit tests use a stub markdown-it (no real markdown-it dependency) and cover the public `extendMarkdownIt()` surface: frontmatter parsing (numeric IDs stay strings, BOM/whitespace stripped, `[[wiki-link]]` disambiguated from `[inline, array]`, `mps-hide: true` opt-out), value rendering (URLs, wiki-links, dates, HTML escaping), the line-number core rules (1-indexed `data-mps-line`, blank-line placeholder injection), and `data-mps-list-depth` tracking on list tokens. +Unit tests use a stub markdown-it (no real markdown-it dependency) and cover the public `extendMarkdownIt()` surface: frontmatter parsing (numeric IDs stay strings, BOM/whitespace stripped, `[[wiki-link]]` disambiguated from `[inline, array]`, `mps-hide: true` opt-out), value rendering (URLs, wiki-links, dates, HTML escaping), the line-number core rules (1-indexed `data-mps-line`, blank-line placeholder injection), `data-mps-list-depth` tracking on list tokens, callout rewriting, the wikilink target parser (`parseWikilinkTarget`) and resolver (`resolveWikilinkTarget`), index machinery (`addToIndex`/`removeFromIndex`), the `mps_wikilink` inline rule against an injected index, the `mps_block_anchors` core rule, and note transclusion paths (resolved hit, embedNotes off, oversized target, cycle cap, image-still-works regression). The visual harness in `test/visual/` runs real markdown-it + our plugin + VS Code's `pluginSourceMap` (copied verbatim from upstream) to produce a faithful DOM clone of the preview. Use it when a CSS/DOM bug needs verification outside VS Code's closed webview - `agent-browser` can attach and report computed styles. It's the only way to distinguish "our CSS is wrong" from "VS Code is serving cached CSS" without manual webview devtools. Test-only dev deps live under `test/visual/` (gitignored `node_modules/`); the repo root stays dependency-free. +`test/visual/fixtures/notes/` holds a small set of files the harness uses to seed the workspace index via the `__setWikiStateForTest` test seam - lets the harness exercise wikilink resolution and `![[note]]` transclusion paths against real `markdown-it`. Add fixtures here when verifying new resolver/embed behaviour visually. + +**Known harness issue (Node 25):** `test/visual/node_modules/mdurl` ships without `build/index.cjs.js` (the file its `package.json` `main` field points to). On Node 25 this surfaces as `MODULE_NOT_FOUND` when `markdown-it` requires `mdurl`. Pre-existing, not caused by anything in this repo - reproducible on a clean `git stash` of all working changes. Workarounds: pin Node ≤ 22 for the harness, or `cd test/visual && npm install mdurl@2.0.0 --force` to grab a tarball that includes the CJS build. The live VS Code preview path is unaffected. + ## Reload after a change | Change | Minimum reload | @@ -34,6 +41,8 @@ The visual harness in `test/visual/` runs real markdown-it + our plugin + VS Cod | `preview.js` | Close + reopen the preview tab | | `package.json` (contributes/capabilities/main) | Full Cmd+Q and relaunch | | Version bump in `package.json` | Rename symlink folder to match, then Cmd+Q | +| `markdownPreviewStyles.wikilinks.*` settings | None - `onDidChangeConfiguration` rebuilds the index live | +| Adding/removing/renaming a `.md` file | None - `FileSystemWatcher` updates the index live | `Developer: Reload Window` is rarely enough - the markdown preview caches its compiled markdown-it instance across window reloads. @@ -140,7 +149,9 @@ Found while implementing `![[image.png]]` embeds. Unit tests caught nothing beca Chromium reports `naturalWidth = naturalHeight = 0` for any SVG whose `` root has no `width`, no `height`, AND no `viewBox` (no intrinsic dimensions per CSS Images spec) — even when the load *succeeded*. Don't use `(img.complete && img.naturalWidth === 0 && img.naturalHeight === 0)` as a "did it fail" heuristic — viewBox-less SVGs (common in Figma/Illustrator exports and hand-written icons) get misclassified as broken. -Better signal: track a `loaded` flag set by an `addEventListener('load', ...)` callback. The race-guard for images that completed before the listener attached becomes `if (img.complete && !loaded) handleError()`. +### Use `img.decode()` for broken-image detection, not a `loaded` flag + +`addEventListener('load', ...)` doesn't fire for listeners attached after the image already completed (cached re-wires). A flag-based race-guard (`if (img.complete && !loaded) handleError()`) misclassifies cached images as broken. Use `img.decode()` — Promise resolves iff the browser can decode, rejects on failure, same behaviour for cache-hit and fresh-fetch. ### Pseudo-elements DO render on a broken `` in this Chromium build @@ -154,21 +165,90 @@ Counterintuitive but verified by live DevTools inspection: VS Code's preview add This means `preview.js` can `getAttribute('src')` and operate on the user's actual path string — no need to parse webview URIs, no `vscode-webview-resource://` URL surgery. The `attachments/` fallback in `preview.js` works because `setAttribute('src', 'attachments/' + name)` re-triggers VS Code's fetch-time resolution. +### `env.currentDocument` is a `vscode.Uri`, not a `TextDocument` + +The `mps_wikilink` renderer needs the previewed document's on-disk path to emit a *document-relative* href (the only href form that routes through VS Code's `openLink` channel for native in-preview navigation — no OS prompt, opens in preview not raw editor). It reads that path from `env.currentDocument` at render time. + +`currentDocument` is a `vscode.Uri`, so its path is at **`cd.fsPath`** — there is NO `cd.uri`. Verified against the 1.122 bundle (`markdown-language-features/dist/extension.js`), where `MarkdownEngine.render` builds the env as: + +```js +{ containingImages: new Set, currentDocument: typeof e === "string" ? void 0 : e.uri, resourceProvider: r, slugifier: ... } +``` + +Reading `cd.uri.fsPath` (the `TextDocument` shape) returns `undefined`, `docPath` falls to `null`, and every resolved wikilink takes the `vscode://file/...` fallback — OS prompt + raw editor. + +**The incremental-edit trap (the load-bearing half).** `currentDocument` is `undefined` on the in-place edit render path. When you type into a preview-to-the-side, VS Code's `renderBody` re-renders with the spliced document *text*: `let o = innerChanges?.length ? uae(e.getText(), innerChanges) : e` — a **string** when there are inner changes — and `MarkdownEngine.render`'s string branch sets `currentDocument: void 0`. So `cd.fsPath` works on the full render (open, save, focus change, scroll, refresh) but vanishes after the first keystroke, re-emitting the `vscode://file` href exactly when you go to click it. A fix that only reads `currentDocument` looks correct on open and regresses on edit — see the "in-place DOM diff" gotcha above for why the morphed-in HTML carries the stale href. + +The robust source is **`env.resourceProvider`** — the `MarkdownPreview` instance itself, passed identically on *both* render paths (`renderDocument(r, this, ...)` and `renderBody(r, this, a)` both forward `this`). It exposes `get resource()` (the previewed doc's Uri). So the renderer reads, in order: `cd.fsPath` (Uri) → `cd.uri.fsPath` (TextDocument, older/future builds) → `rp.resource.fsPath` (survives the incremental path) → `env.resource.fsPath` (speculative — never observed in 1.122) → `null` (→ `vscode://file` fallback). Verified live: on every render, both `cd.fsPath` and `rp.resource.fsPath` carried the correct absolute path and agreed. The visual harness calls `md.render(src)` with no env, so it never exercises any of this — only the live preview (or a render-level test passing a Uri-shaped `currentDocument` / a `{resource}`-bearing `resourceProvider`) surfaces it. + +How the relative href reaches preview-mode navigation: the webview click handler (`media/index.js`) posts `openLink` for any schemeless href; the extension host's `resolveLinkTarget(href, resource)` runs `resolveInternalDocumentLink`, which `joinPath(dirname(previewResource), href)` for a relative href (and re-roots to the workspace folder for a `/`-leading one — which is why bare-absolute hrefs ENOENT-double). When `markdown.preview.openMarkdownLinks` is `inPreview` (the default) and the target is markdown, it opens in the preview. A cross-root `extraIndexRoots` target therefore works too: `path.relative` yields a `../../…/vault/note.md` chain that joins back to the absolute path. `data-href` is deliberately NOT emitted on the wikilink `` (VS Code only adds it to its own `link_open` tokens): the click handler falls back to `href` when `data-href` is absent, so the schemeless `href` alone is sufficient. Adding it would be cargo-cult. + +Known edge cases left unhandled (rare, no clean fix): wikilinking from an untitled/unsaved buffer (`Uri.fsPath` of an `untitled:` doc is a dirless junk path, so `path.relative` misfires — the old `vscode://file` path happened to work there); and Windows cross-drive targets (`path.win32.relative` returns a `D:\…` absolute → the click handler's `/^[a-z\-]+:/i` treats `D:` as a non-allowlisted scheme → silent drop). Irrelevant to this macOS-only sideload; documented so a future port knows. + +### `activate(context)` requires `vscode`, but unit tests don't have one + +Wikilink resolution needs `vscode.workspace.findFiles` + `FileSystemWatcher`, so `require('vscode')` is mandatory inside `activate`. But the unit-test harness runs in plain Node with no VS Code extension host - `require('vscode')` throws `MODULE_NOT_FOUND`. Wrap the require in try/catch and treat absence as "no workspace context, skip index init": + +```js +let vscode; +try { vscode = require('vscode'); } catch (_) { return; } +``` + +Test stubs reach the same code via `activate({ subscriptions: [] })` - the `subscriptions` property is the duck-type the production VS Code `context` always has. If a test stub omits it (`activate()` with no arg), the gate `if (context && context.subscriptions)` keeps the init path off. + +### Module-level mutable state lives in `extension.js` now + +Before the wikilink upgrade, `extension.js` had zero module-level mutable state - every render was a pure transformation of the source string. The workspace index changes that: `wikiIndex` (Map), `wikiConfig` (resolved settings), `wikiRoots` (canonical paths), `_activeWatchers` (disposables across rebuilds), and the injectable I/O hooks (`wikiReadFile`, `wikiStatFile`) all live at module top-level. The inline rules close over them via the `resolveWikilinkTarget` / `tryEmitTranscludeToken` helpers. + +Consequence for tests: state leaks across tests unless reset. Use `__setWikiStateForTest({ index, config, readFile, statFile })` to inject and `__resetWikiStateForTest()` to clear. The `withIndex` / `withTranscludeFixtures` helpers in `test/test.js` wrap this in try/finally so a thrown assertion doesn't poison the next test. + +### Calling `md.render()` from inside a renderer rule is fine - VS Code's "doesn't call render" gotcha is about INCOMING, not outgoing + +The earlier gotcha "VS Code's preview does NOT call `md.render()`" is about VS Code invoking *our* plugin - it bypasses `md.render` and calls `parse + renderer.render` directly, so wrapping `md.render` to intercept input does nothing. **We are still free to call `md.render()` ourselves on derived input.** The note-transclusion path does exactly this: the `mps_embed_note` renderer reads the target file, slices to the fragment, and recurses via `md.render(slice, env)` to produce the embedded body. The recursion guard is `env.mpsEmbedDepth` (capped at 2) carried through the env object. + +This is *not* a "wrap VS Code's render" - it's a fresh render call on a fresh substring with no expectation that VS Code's own preview pipeline will route through it. Safe pattern. + +But the recursion re-runs the **entire core-rule pipeline** on the embedded slice, and some core rules emit host-document-scoped output that must NOT run inside an embed. `mps_blank_lines` would stamp `data-line` / `data-mps-line` numbered from the *slice's* line 0 (colliding with the host's line numbers, misleading VS Code's active-line tracker and double-click-to-jump) and `mps_block_anchors` would emit a second `id="mps-block-"` (duplicate id, invalid HTML, ambiguous scroll target). Both gate on `state.env.mpsEmbedDepth`: `mps_blank_lines` skips entirely (embedded content carries no gutter); `mps_block_anchors` still strips the `^id` marker for clean text but suppresses the id. `mps_callouts` deliberately still runs (callouts are content, not source-mapping); `mps_frontmatter` is a no-op because the slice has already had its frontmatter stripped. Any new source-mapping core rule must add the same `mpsEmbedDepth` gate. + +### Heading anchors must match VS Code's GitHub slugifier + +A `[[note#heading]]` href anchor (`#slug`) only scrolls to the heading if `slug` equals the `id` VS Code renders on the heading element. The extension does NOT set heading ids itself - VS Code's preview does, via the **GitHub slugifier** (verified against the 1.122 bundle: `heading.trim().toLowerCase().replace(, "").replace(/\s/g, "-")`). The single `slugifyHeading` helper reproduces it with `replace(/[^\p{L}\p{N}\p{M}_\- ]/gu, "").replace(/\s/g, "-")` - the key properties a naive `[^a-z0-9-]` slug gets wrong: **Unicode letters are kept** (`Café` → `café`, not `caf`) and **whitespace is replaced per-character not collapsed** (`a b` → `a--b`). Do not "simplify" it back to an ASCII-only class. Not reproduced: the `-1`/`-2` suffixing VS Code adds to duplicate headings (a wikilink to a repeated heading targets the first). `env.slugifier` is a stateful builder (`add` only, dedup-tracking) so it can't be borrowed for a one-off fragment without corrupting the ToC counter - hence the standalone helper. + +### The bespoke wikilink parser is the deliberate choice + +`markdown-it-wikilinks` exists, supports the pact subset (`name|alias|#heading`), and is what Foam uses. We did not adopt it. Reasons (capturing so future-me doesn't re-litigate): + +- **No-runtime-deps rule (Project conventions below).** Adopting the library means either an `npm install` step that violates the no-`node_modules` invariant, or vendoring an unmaintained-since-2023 file we now own anyway. +- **Block refs and non-image transclusion are layered on top regardless** - the library doesn't help with those. +- **Total bespoke parser is ~50 lines.** Worth owning for the invariant. + +`parseWikilinkTarget` (canonical fragment-before-pipe order: `name(#heading|^block)?(\|alias)?`) is THE parser - all paths use it. Don't add a second one. + +### Workspace index ordering across multiple roots + +Within a single root, ordering is shortest-path then alphabetical (intuitive "the closest file wins"). Across roots (workspace folders + `extraIndexRoots`), the `rootSortKey` (canonicalised root path) dominates - so "shortest path" only carries meaning *within* one root. A deeply nested file in an earlier-alphabetical root beats a top-level file in a later root with the same basename. README documents this; tests cover it (`resolveWikilinkTarget: cross-root ordering by rootSortKey then path`). + +Don't try to make "shortest path" global - it would require a notion of "canonical" root that the user can't reasonably specify. + ## Project conventions -- **No runtime dependencies.** The in-tree `parseFrontmatter` is intentional - it covers Obsidian-shaped frontmatter (top-level scalars, block/inline string arrays). Do not propose adding `js-yaml` without an explicit conversation. Test-only dev deps in `test/` would be acceptable if the gap matters. +- **No runtime dependencies.** The in-tree `parseFrontmatter` and `parseWikilinkTarget` are intentional - they cover the shape we need without pulling in `js-yaml` or `markdown-it-wikilinks`. Do not propose adding either without an explicit conversation; see "bespoke wikilink parser is the deliberate choice" gotcha above for the wikilink-parser rationale. Test-only dev deps in `test/` would be acceptable if the gap matters. - **No `node_modules`.** Same reason. Stated invariant in the README. - **Numeric-looking values stay strings.** `parseScalar` deliberately doesn't `parseInt` / `parseFloat`. Preserves IDs like `task-id: 20260101`. Don't re-introduce numeric parsing. - **`:where()` for body-level resets** so user or theme CSS can still override. - **`rem`, not `em`, for line-number font-size** - `em` scales with parent so numbers next to headings would render larger. - **British English** in user-facing strings and docs (centring, colour). Code identifiers stay as the syntax requires (CSS `color`, etc.). - **`[[wiki-link]]` is treated as a string in frontmatter.** `parseFrontmatter` checks for `[[...]]` before falling through to the `[a, b]` inline-array branch, otherwise `parent: [[TASK-123]]` would parse as a one-element array. +- **Public repo - no personal paths in shipped files.** `README.md` setting examples use `~/Documents/notes`, not the iCloud vault path. `package.json` configuration descriptions stay generic. Personal config (e.g. an actual `extraIndexRoots` vault entry) goes in user settings only. +- **Settings UX: zero-config working case.** The four `markdownPreviewStyles.wikilinks.*` keys default such that the extension works for a fresh user with nothing configured. `enabled: true` (workspace-wide resolution on), `extraIndexRoots: []` (no extra surface), `embedNotes: true` (transclusion on), `embedMaxBytes: 262144` (safe cap). Don't change defaults without considering the no-config case. +- **Workspace-aware character.** The extension started as a pure markdown-it plugin with no awareness of VS Code's API. The wikilink upgrade added `require('vscode')`, workspace indexing, `FileSystemWatcher`s, and `onDidChangeConfiguration` handling - a real VS Code extension shape. New features can take advantage of this (e.g. read settings, query workspace state). But the markdown-it pipeline itself should stay framework-free where possible - `parseWikilinkTarget` and `resolveWikilinkTarget` have no `vscode` dependency, which is what makes them unit-testable in plain Node. ## Files -- `extension.js` - `activate()` + `extendMarkdownIt(md)`. Two core rules (`mps_blank_lines`, `mps_frontmatter`), one inline rule (`mps_wikilink`) and its renderer. -- `style.css` - all CSS contributed via `markdown.previewStyles`. Line-number gutter, hover-indicator suppression, Properties table, heading and inline-code tweaks. -- `preview.js` - contributed via `markdown.previewScripts`, runs in the webview. Measures each `.code-line`'s `offsetLeft` and sets `--mps-before-left` so the line-number gutter aligns at every nesting depth. +- `extension.js` - `activate(context)` + `extendMarkdownIt(md)`. Four core rules (`mps_block_anchors`, `mps_callouts`, `mps_blank_lines`, `mps_frontmatter`), two inline rules (`mps_embed`, `mps_wikilink`) and their renderers (`mps_wikilink`, `mps_embed_note`). Workspace `.md` index built on activate, maintained by per-root `FileSystemWatcher`s. Module-level mutable state: `wikiIndex`, `wikiConfig`, `wikiRoots`, `_activeWatchers`, `wikiReadFile`, `wikiStatFile`. Exports the resolver / parser / index helpers + `__setWikiStateForTest` / `__resetWikiStateForTest` for tests and the visual harness. +- `style.css` - all CSS contributed via `markdown.previewStyles`. Line-number gutter, hover-indicator suppression, Properties table, callouts, heading and inline-code tweaks, image embeds (`mps-embed-image` + broken-image fallback), note transclusion (`mps-embed-note` container + `mps-embed-cycle` style). +- `preview.js` - contributed via `markdown.previewScripts`, runs in the webview. Measures each `.code-line`'s `offsetLeft` and sets `--mps-before-left` so the line-number gutter aligns at every nesting depth. Also handles the `attachments/` retry and broken-image styling for image embeds. - `example.md` - exercises every feature; preview to visually verify changes. -- `test/test.js` - assertions against a stub markdown-it (no real markdown-it dep). Run via `node test/test.js`. -- `package.json` - manifest. Contributes `markdown.markdownItPlugins: true` and `markdown.previewStyles: ["./style.css"]`. `capabilities.untrustedWorkspaces.supported: true` so the extension runs in restricted-mode workspaces. +- `test/test.js` - assertions against a stub markdown-it (no real markdown-it dep). Run via `node test/test.js`. 100+ tests across frontmatter, value rendering, wikilink/embed inline rules, line-number core rules, callouts, wikilink parser, workspace resolver, index machinery, mps_wikilink with workspace resolution, block-anchor rule, and note transclusion. +- `test/visual/` - real-`markdown-it` harness (see "visual harness" section above). `fixtures/notes/` seeds the workspace index for wikilink/transclude verification. +- `package.json` - manifest. Contributes `markdown.markdownItPlugins: true`, `markdown.previewStyles: ["./style.css"]`, `markdown.previewScripts: ["./preview.js"]`, and `configuration` with the four `markdownPreviewStyles.wikilinks.*` keys. `capabilities.untrustedWorkspaces.supported: true` and `virtualWorkspaces: true` so the extension runs in restricted-mode workspaces. diff --git a/README.md b/README.md index 93b2d20..2aea0d8 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,43 @@ The extension's defaults assume a few preview settings. Only `breaks` differs fr - Default block margins on body content are zeroed so vertical spacing comes from blank-line placeholders - one source line ≈ one visual row, matching the editor's gutter rhythm. - Inline code (backtick-quoted spans) shrunk to `0.9em`. Fenced code blocks inside `
` are untouched.
 - Renders YAML frontmatter as a Properties table with type-aware icons (text / list / tags / date / datetime / checkbox) and pill chips for `tags` and string arrays. Non-editable (v1).
-- Auto-links `https://...` URLs in Properties values; styles `[[wiki-links]]` everywhere (in Properties values and document body) as `` tags with basename-without-extension visible text - so `[[notes/2026-meeting]]` displays as `2026-meeting`. VS Code's webview resolves relative `href`s on click via the document-link handler.
-- Renders Obsidian-style image embeds (`![[image.png]]`, with optional `![[image.png|N]]` for a px width). Path resolution is document-relative; bare filenames are retried under `attachments/` on first error. Non-image extensions degrade to a wiki-link rather than an embed. Failed loads show a dashed placeholder with the original path.
+- Resolves `[[wiki-links]]` against a **workspace-wide index** of every `.md` file under the open folders (plus any extra roots configured via `markdownPreviewStyles.wikilinks.extraIndexRoots`). Case-insensitive basename match; shortest-path tiebreak on collision. Supports the full Obsidian/Foam syntax matrix - see [Wikilink syntax](#wikilink-syntax) below.
+- Renders Obsidian-style image embeds (`![[image.png]]`, with optional `![[image.png|N]]` for a px width). Bare filenames are retried under `attachments/` on first error. Failed loads show a dashed placeholder with the original path.
+- Renders `![[note]]` (non-image) embeds **inline** as transclusions - the referenced note's body (frontmatter stripped) renders inside an `mps-embed-note` container. Optional `#heading` or `^block` fragment narrows the embed to that section. Recursive embeds are capped at depth 2 to prevent cycles.
 - Add `mps-hide: true` to a file's frontmatter to suppress the Properties table for that file.
 
+## Wikilink syntax
+
+| Form | Renders as |
+|---|---|
+| `[[name]]` | Link to `name.md` (resolved workspace-wide by basename). |
+| `[[name\|alias]]` | Link to `name.md`, displaying `alias`. Pipe-after-name (Obsidian/Foam convention - not GitHub Wiki's pipe-before-name). |
+| `[[name#heading]]` | Link to `name.md` and scroll to `#heading`. |
+| `[[name^block]]` | Link to `name.md` and scroll to the paragraph or list-item carrying a trailing `^block` marker. |
+| `[[name#heading\|alias]]` | Combined - canonical fragment-before-pipe order. |
+| `![[image.png]]` | Inline image (with optional `\|N` for width). |
+| `![[name]]` | Inline transclusion of the referenced note's body. |
+| `![[name#heading]]` / `![[name^block]]` | Inline transclusion narrowed to that section/block. |
+
+Reverse-order combined forms (`[[name|alias#heading]]`) are **not supported** - the trailing `#heading` becomes part of the alias display text and there is no anchor jump. The canonical fragment-before-pipe order matches Obsidian and the `markdown-it-wikilinks` parser.
+
+Resolution is **case-insensitive on basename**. Multiple matches resolve by shortest path (fewest separators), then alphabetical within the same depth. When indexing multiple roots (workspace folders + `extraIndexRoots`), entries are ordered alphabetically by root path first - so shortest-path only carries meaning *within* a single root.
+
+To pin a block as a scroll target, append `^my-id` at the end of a paragraph or list item. The extension strips the marker from the rendered text and adds `id="mps-block-my-id"` to the wrapping element so links can scroll to it.
+
+## Settings
+
+All settings live under `markdownPreviewStyles.wikilinks.*`:
+
+| Key | Default | Purpose |
+|---|---|---|
+| `enabled` | `true` | Master switch. Off = document-relative resolution (no workspace index). |
+| `extraIndexRoots` | `[]` | Extra folders to index alongside the open workspace. Useful when you keep notes outside the workspace (e.g. `~/Documents/notes`). Tilde-prefixed paths are expanded. Missing paths are skipped with a warning. |
+| `embedNotes` | `true` | Whether `![[name]]` for non-image targets renders inline (`true`) or as a link with the `mps-embed-fallback` style (`false`). |
+| `embedMaxBytes` | `262144` (256KB) | File size cap for inline transclusion. Larger targets degrade to a link. |
+
+The four keys appear in VS Code's Settings UI under "Markdown Preview Styles → Wikilinks".
+
 ## Supported frontmatter
 
 Top-level scalars (string, boolean, null, ISO date `YYYY-MM-DD`, ISO datetime `YYYY-MM-DDTHH:MM[...]`), block-style arrays (`tags:` followed by `  - foo`), and inline arrays (`tags: [foo, bar]`). Numeric values stay as strings to preserve IDs like `task-id: 20260101`. Nested objects, multiline strings, anchors, and flow maps are not supported.
@@ -64,9 +97,10 @@ Date-only values are formatted without timezone shift so the day always matches
 
 ## Known limitations
 
-- **Wiki-link resolution is document-relative, not vault-wide.** A bare `[[note-name]]` won't find `note-name.md` somewhere else in the workspace - it tries to resolve relative to the current file. For image embeds, the resolver also retries with an `attachments/` prefix on first error. Vault-wide basename resolution would need a workspace index and is out of scope for now.
-- **Wiki-link `` clicks go through VS Code's webview link handler.** Non-existent targets surface a "file not found" toast rather than navigating anywhere - no in-preview broken-link styling.
-- **Image embed visible-text change.** Wiki-link display text is the path's basename without extension (matches Obsidian). Existing notes that used directory-prefixed wiki-links like `[[notes/2026-meeting]]` now show just `2026-meeting`. The full path remains in the `href`.
+- **Workspace index is built asynchronously on activation.** Open previews are refreshed automatically once the index finishes building, but there's a brief window where wikilinks render with document-relative hrefs before the refresh fires.
+- **Watcher behaviour on iCloud-synced roots is noisy.** If you point `extraIndexRoots` at an iCloud folder, the file watcher fires on sync events as well as edits. Index correctness is unaffected; CPU may briefly spike on heavy sync activity.
+- **Wiki-link `` clicks go through VS Code's webview link handler.** Non-existent targets (no index match and no document-relative file) surface a "file not found" toast rather than navigating anywhere - no in-preview broken-link styling.
+- **Cross-root collision ordering.** When two indexed roots both contain a file with the same basename, the resolver orders entries alphabetically by root path then by relative path. "Shortest path" only applies within a single root.
 
 ## Development
 
diff --git a/example.md b/example.md
index d52d0d6..eaa1cf2 100644
--- a/example.md
+++ b/example.md
@@ -59,11 +59,26 @@ Second line, no blank line above it.
 
 URLs in body text are handled by markdown-it: .
 
-Wiki-links in body text get the same `.mps-wiki-link` styling as Properties values:
+Wiki-links in body text get the same `.mps-wiki-link` styling as Properties values, and resolve against a workspace-wide index of `.md` files. Clicking a resolved link navigates in-place (no OS prompt, preview mode), the same as a plain `[text](relative.md)` link.
 
-**See**: [[related-document]] for background (one step → another step → final step).
+### Wiki-link and embed formats
 
-The version in backticks renders as literal text for contrast: `[[related-document]]`. Wiki-links are still not clickable.
+Every supported `[[...]]` / `![[...]]` form, with a live example resolving against the fixtures in `test/visual/fixtures/notes/`:
+
+- **Basic** - `[[related-document]]` → [[related-document]]. Resolved by basename, case-insensitively; the shortest path wins on collision.
+- **Heading fragment** - `[[short-note#Section A]]` → [[short-note#Section A]]. The href anchor is slugified to match the heading's id.
+- **Block fragment** - `[[old-task^archived-block]]` → [[old-task^archived-block]]. Targets a `^block-id` marker (block ids are `[A-Za-z0-9_-]`).
+- **Alias** - `[[related-document|see the neighbour]]` → [[related-document|see the neighbour]]. Resolves the name, displays the alias. Fragment goes before the pipe: `[[name#heading|alias]]`.
+- **Same-document fragment** - `[[#Lists]]` → [[#Lists]]. Empty name, so it links to a heading in *this* file.
+- **Folder-qualified** - `[[notes/short-note]]` → [[notes/short-note]]. A path prefix is accepted (Foam/Dendron style); only the final basename is matched.
+- **Note transclusion** - `![[short-note#Section A]]` inlines the target's content in an `.mps-embed-note` container, falling back to a link when the target is missing, over `embedMaxBytes`, or a cycle is detected (block demo below).
+- **Image embed** - `![[image.png]]` renders the file inline; `![[image.png|N]]` constrains the width to N px (aspect ratio preserved). Resolves document-relative, not via the index (block demos in *Image embeds* below).
+
+A `[[...]]` with no index match stays a document-relative link, and the backtick form `` `[[related-document]]` `` renders as literal text.
+
+Note transclusion, `![[short-note#Section A]]`:
+
+![[short-note#Section A]]
 
 ## Lists
 
@@ -118,7 +133,7 @@ Task list with `- [ ]` and `- [x]`:
 
 ## Image embeds
 
-Obsidian-style image embeds. `![[path]]` renders the file inline if the extension is image-like; `![[path|N]]` constrains the width to N px (aspect ratio preserved). Path resolution is document-relative.
+The `![[image]]` format from the list above, at block size to exercise width and broken-image handling.
 
 Bare embed (full width up to the 880px preview cap):
 
diff --git a/extension.js b/extension.js
index 955bd9d..549fde6 100644
--- a/extension.js
+++ b/extension.js
@@ -2,8 +2,322 @@
 // Extracts YAML frontmatter from each preview's source and prepends a
 // Properties table above the rendered markdown. Non-editable in v1.
 
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
 const FRONTMATTER_RE = /^---\r?\n([\s\S]+?)\r?\n---\s*(?:\r?\n|$)/;
 
+// ---- Wikilink resolver state -----------------------------------------------
+//
+// Module-level state populated by activate(context). The extendMarkdownIt
+// rules close over these via the accessor functions below, so tests can
+// swap state without re-registering rules.
+//
+// `wikiIndex` is a Map of absolute paths. Each
+// bucket is pre-sorted: within a single indexed root, ascending by separator
+// count then alphabetical (so resolveWikilinkTarget can return the head).
+// Across roots (multi-root + extraIndexRoots), entries are interleaved by
+// alphabetical-by-root-path, then by relative-path within the root.
+let wikiIndex = new Map();
+let wikiConfig = {
+  enabled: true,
+  extraIndexRoots: [],
+  embedNotes: true,
+  embedMaxBytes: 262144,
+};
+// Roots are tracked alongside the index for cross-root tiebreak ordering.
+// Each entry is { absPath, sortKey } - sortKey is the canonicalised path
+// used for ordering. Filled by activate(); empty in unit tests.
+let wikiRoots = [];
+
+// Injectable for tests so transclusion can run without real disk I/O.
+let wikiReadFile = (absPath) => fs.readFileSync(absPath, 'utf8');
+let wikiStatFile = (absPath) => fs.statSync(absPath);
+
+function __setWikiStateForTest(state) {
+  if (state.index !== undefined) wikiIndex = state.index;
+  if (state.config !== undefined) wikiConfig = { ...wikiConfig, ...state.config };
+  if (state.roots !== undefined) wikiRoots = state.roots;
+  if (state.readFile !== undefined) wikiReadFile = state.readFile;
+  if (state.statFile !== undefined) wikiStatFile = state.statFile;
+}
+
+function __resetWikiStateForTest() {
+  wikiIndex = new Map();
+  wikiConfig = { enabled: true, extraIndexRoots: [], embedNotes: true, embedMaxBytes: 262144 };
+  wikiRoots = [];
+  wikiReadFile = (absPath) => fs.readFileSync(absPath, 'utf8');
+  wikiStatFile = (absPath) => fs.statSync(absPath);
+}
+
+// Parse the inner content of a [[...]] or ![[...]] into its three parts.
+// Canonical order is fragment-before-pipe: `name(#heading|^block)?(\|alias)?`.
+// Reverse order (`name|alias#heading`) is NOT recognised - the pipe wins,
+// so `alias#heading` is the literal label and the trailing fragment is part
+// of the display text rather than a scroll target. This matches Obsidian's
+// canonical form and `markdown-it-wikilinks`' native parse.
+function parseWikilinkTarget(inner) {
+  if (typeof inner !== 'string') return { name: '', fragment: null, alias: null };
+  // Split at the FIRST pipe. Everything to the right is the alias verbatim.
+  const pipeIdx = inner.indexOf('|');
+  const namePart = pipeIdx >= 0 ? inner.slice(0, pipeIdx) : inner;
+  const alias = pipeIdx >= 0 ? inner.slice(pipeIdx + 1) : null;
+  // Fragment lives on the name side, anchored at the end of namePart so a `#`
+  // in the basename (unlikely but legal) isn't mistaken for a heading marker.
+  //   `^block`   - restricted to the canonical block-id charset, so `^a b`
+  //                isn't half-parsed into an id the anchor can never match.
+  //   `#heading` - any run not containing another `#`/`^` (slugified later).
+  // The name part may be empty: `[[#heading]]` / `[[^block]]` are valid
+  // same-document fragment links (the resolver/renderer treat an empty name as
+  // "current document").
+  let name = namePart;
+  let fragment = null;
+  const blockMatch = namePart.match(new RegExp('^(.*?)(\\^' + BLOCK_ID_RE.source + ')$'));
+  const headingMatch = namePart.match(/^(.*?)(#[^#^]+)$/);
+  if (blockMatch) {
+    name = blockMatch[1];
+    fragment = blockMatch[2];
+  } else if (headingMatch) {
+    name = headingMatch[1];
+    fragment = headingMatch[2];
+  }
+  return { name, fragment, alias };
+}
+
+// Sort comparator for an index bucket. Across roots (multi-root + extra),
+// the root sort key dominates. Within a single root, ascending by separator
+// count (shortest path wins on basename collision), then alphabetical for a
+// deterministic final tiebreak. Roots are sorted alphabetically by their
+// canonicalised path so "shortest path" only carries meaning within one root.
+function indexBucketCompare(a, b) {
+  if (a.rootSortKey !== b.rootSortKey) {
+    return a.rootSortKey < b.rootSortKey ? -1 : 1;
+  }
+  const sepA = (a.absPath.match(/\//g) || []).length;
+  const sepB = (b.absPath.match(/\//g) || []).length;
+  if (sepA !== sepB) return sepA - sepB;
+  return a.absPath < b.absPath ? -1 : (a.absPath > b.absPath ? 1 : 0);
+}
+
+function addToIndex(index, absPath, rootSortKey) {
+  const basename = path.basename(absPath, path.extname(absPath)).toLowerCase();
+  const bucket = index.get(basename) || [];
+  if (bucket.some(e => e.absPath === absPath)) return;
+  bucket.push({ absPath, rootSortKey: rootSortKey || '' });
+  bucket.sort(indexBucketCompare);
+  index.set(basename, bucket);
+}
+
+function removeFromIndex(index, absPath) {
+  const basename = path.basename(absPath, path.extname(absPath)).toLowerCase();
+  const bucket = index.get(basename);
+  if (!bucket) return;
+  const filtered = bucket.filter(e => e.absPath !== absPath);
+  if (filtered.length === 0) index.delete(basename);
+  else index.set(basename, filtered);
+}
+
+// Resolve a wikilink target name (already stripped of fragment and alias)
+// to an absolute path via the workspace index. Returns null on miss.
+// Case-insensitive on the basename to match Obsidian. Tolerates a trailing
+// `.md` (Obsidian writes `[[name]]` for `name.md` but accepts `[[name.md]]`)
+// and a folder prefix in the wikilink (`[[folder/name]]` - Foam/Dendron form)
+// by looking up only the final basename.
+function resolveWikilinkTarget(name, indexOverride) {
+  const idx = indexOverride || wikiIndex;
+  if (!name) return null;
+  const stripped = name.replace(/\.md$/i, '');
+  const basename = stripped.replace(/^.*\//, '').toLowerCase();
+  const hits = idx.get(basename);
+  if (!hits || hits.length === 0) return null;
+  return hits[0].absPath;
+}
+
+// Strip YAML frontmatter from a markdown source string.
+function stripFrontmatter(src) {
+  return src.replace(FRONTMATTER_RE, '');
+}
+
+// Slice a markdown source string to the section under `#heading` or the
+// single block carrying `^block-id`. Returns the full body when fragment
+// is null. Returns '' when the fragment isn't found - caller decides
+// whether that's a fallback condition.
+function sliceToFragment(src, fragment) {
+  if (!fragment) return src;
+  if (fragment[0] === '^') {
+    const id = fragment.slice(1);
+    const re = new RegExp('^(.*\\s\\^' + id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')\\s*$', 'm');
+    const m = src.match(re);
+    return m ? m[1].replace(new RegExp('\\s+\\^' + BLOCK_ID_RE.source + '\\s*$'), '') : '';
+  }
+  // Heading fragment: find a heading line matching (case-insensitive,
+  // slug-equivalent) the requested heading, take content until the next
+  // same-or-higher-level heading.
+  const wantSlug = slugifyHeading(fragment.slice(1));
+  const lines = src.split(/\r?\n/);
+  let startIdx = -1;
+  let startLevel = 0;
+  for (let i = 0; i < lines.length; i++) {
+    const m = lines[i].match(/^(#{1,6})\s+(.+?)\s*$/);
+    if (!m) continue;
+    const slug = slugifyHeading(m[2]);
+    if (slug === wantSlug) {
+      startIdx = i + 1;
+      startLevel = m[1].length;
+      break;
+    }
+  }
+  if (startIdx < 0) return '';
+  const out = [];
+  for (let i = startIdx; i < lines.length; i++) {
+    const m = lines[i].match(/^(#{1,6})\s+/);
+    if (m && m[1].length <= startLevel) break;
+    out.push(lines[i]);
+  }
+  return out.join('\n');
+}
+
+// Try to emit an mps_embed_note token for a non-image embed target.
+// Returns true if a transclude token was pushed (caller skips the
+// wiki-link fallback). Returns false on any condition that prevents
+// transclusion - the caller's wiki-link fallback then runs.
+function tryEmitTranscludeToken(state, path) {
+  if (!wikiConfig.enabled || !wikiConfig.embedNotes) return false;
+  const parsed = parseWikilinkTarget(path);
+  if (!parsed.name) return false;
+  const resolved = resolveWikilinkTarget(parsed.name);
+  if (!resolved) return false;
+  // Cheap size gate before reading. Skip if stat fails - the renderer
+  // will surface the failure via the cycle/fallback path.
+  try {
+    const stat = wikiStatFile(resolved);
+    if (stat.size > wikiConfig.embedMaxBytes) return false;
+  } catch (_) {
+    return false;
+  }
+  const tok = state.push('mps_embed_note', '', 0);
+  tok.meta = {
+    resolvedPath: resolved,
+    fragment: parsed.fragment,
+    alias: parsed.alias,
+    originalTarget: path,
+  };
+  return true;
+}
+
+// Slugify a heading the way VS Code's preview does, so a `[[note#heading]]`
+// href anchor matches the `id` VS Code renders on the heading element. This is
+// the GitHub slugifier (verified against the 1.122 bundle's GithubSlugifier):
+// trim, lowercase, strip punctuation/symbols, whitespace → '-'.
+//
+// The character class keeps Unicode letters/numbers/marks plus `_`/`-` and
+// strips everything else - so `Café` → `café` and `へや 部屋` → `へや-部屋`,
+// matching the bundle. (The bundle uses an explicit ~2KB Unicode code-point
+// regex; the \p{...} classes reproduce its output for any realistic heading
+// without vendoring that table.) Whitespace is replaced per-character (not
+// collapsed), so `a  b` → `a--b`, again matching GitHub. Duplicate-heading
+// `-1`/`-2` disambiguation is NOT reproduced - a wikilink to a repeated
+// heading targets the first.
+function slugifyHeading(text) {
+  return String(text).trim().toLowerCase()
+    .replace(/[^\p{L}\p{N}\p{M}_\- ]/gu, '')
+    .replace(/\s/g, '-');
+}
+
+// Canonical block-id character class. A `^block` reference and the
+// `id="mps-block-"` anchor it targets must agree on what a block id is, or
+// the link resolves to nothing. mps_block_anchors only ever mints ids from
+// this class, so parseWikilinkTarget and the slice/anchor paths use it too.
+const BLOCK_ID_RE = /[A-Za-z0-9_-]+/;
+
+// Convert a parsed fragment into the URL-anchor form used in hrefs.
+// `#heading` → `#heading-slug`. `^block` → `#mps-block-`. null → ''.
+function fragmentToAnchor(fragment) {
+  if (!fragment) return '';
+  if (fragment[0] === '^') {
+    return '#mps-block-' + fragment.slice(1);
+  }
+  return '#' + slugifyHeading(fragment.slice(1));
+}
+
+// Percent-encode each path segment, preserving the `/` separators. Used in
+// place of encodeURI for hrefs because encodeURI leaves `#`, `?`, and `&`
+// unescaped - a resolved target named e.g. `report?draft.md` would otherwise
+// emit an href the click handler truncates at the `?`. encodeURIComponent
+// escapes all three, and splitting on `/` keeps the path structure intact.
+function encodePathSegments(p) {
+  return String(p).split('/').map(encodeURIComponent).join('/');
+}
+
+// Pull the previewed document's on-disk path from the render env. VS Code
+// populates env with `{ currentDocument, containingImages, slugifier,
+// resourceProvider }` for the markdown preview.
+//
+// currentDocument is a vscode.Uri, NOT a TextDocument - verified against the
+// 1.122 bundle, where MarkdownEngine.render builds `currentDocument: typeof e
+// == "string" ? void 0 : e.uri`. A Uri exposes `.fsPath` directly and has NO
+// `.uri` property. The `cd.uri.fsPath` arm is a fallback for the TextDocument
+// shape (older/future builds).
+//
+// currentDocument is undefined on the incremental render path: when you type
+// into a preview-to-the-side, VS Code re-renders with the spliced document
+// TEXT (a string), and the string branch sets `currentDocument: void 0`. So
+// relying on currentDocument alone re-introduces the vscode://file fallback
+// after every keystroke. env.resourceProvider is the MarkdownPreview itself
+// (passed identically on both render paths) and exposes `.resource` (the
+// previewed document's Uri) - so it survives the incremental path.
+function docPathFromEnv(env) {
+  const cd = env && env.currentDocument;
+  const rp = env && env.resourceProvider;
+  return (cd && cd.fsPath) ||
+         (cd && cd.uri && cd.uri.fsPath) ||
+         (rp && rp.resource && rp.resource.fsPath) ||
+         (env && env.resource && env.resource.fsPath) ||
+         null;
+}
+
+// Build the href for a wikilink whose target RESOLVED to an absolute disk path.
+// Three shapes:
+//   1. docPath known → a path relative from the previewed document to the
+//      target. VS Code's click handler sees a schemeless string, posts an
+//      `openLink` message, and the extension host resolves it relative to the
+//      preview resource - in-preview navigation, no OS prompt, preview mode.
+//      This is the path built-in `[text](relative.md)` links take.
+//   2. docPath unknown → `vscode://file/...` URI. The `vscode:` scheme is in
+//      the click-handler allowlist, so the OS routes it back to VS Code. Costs
+//      one OS prompt and opens the raw editor, but works across any path.
+//   3. target IS the previewed document AND there's a fragment (self-link
+//      like [[current#section]]) → just the fragment, so the click scrolls in
+//      place instead of reloading. A bare self-link ([[current]], no fragment)
+//      falls through to the relative path (its own basename) - a normal
+//      reload link, NOT an empty href (which the renderer would mistake for a
+//      rejected dangerous scheme and render as an inert span).
+// Bare-absolute and `file://` hrefs were both tried and are wrong: VS Code
+// concatenates a bare-absolute onto the preview dir (ENOENT on the doubled
+// path), and `file://` is dropped by the click handler's scheme tests.
+// resolvedPath is sourced from vscode.workspace.findFiles - not user input -
+// so it doesn't need safeHref screening.
+function buildResolvedHref(resolvedPath, fragment, docPath) {
+  const anchor = fragmentToAnchor(fragment);
+  if (docPath) {
+    if (resolvedPath === docPath && anchor) return anchor; // self-link w/fragment → scroll in place
+    let rel = path.relative(path.dirname(docPath), resolvedPath);
+    rel = rel.split(path.sep).join('/'); // Windows backslashes → URL slashes
+    return encodePathSegments(rel) + anchor;
+  }
+  return 'vscode://file' + encodePathSegments(resolvedPath) + anchor;
+}
+
+// Expand `~` and `~/...` in a path string. No-op on absolute paths.
+function expandTilde(p) {
+  if (typeof p !== 'string' || !p.startsWith('~')) return p;
+  if (p === '~') return os.homedir();
+  if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
+  return p; // ~user form not supported - rare and platform-specific
+}
+
+
 function escapeHtml(s) {
   return String(s).replace(/[&<>"']/g, c => (
     { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
@@ -197,7 +511,7 @@ function formatDate(value) {
 // from an already-escaped string and then escaped the href again), and the
 // URL linkifier matching URLs inside an emitted 's attribute value
 // (because it ran second on a string that already contained anchor HTML).
-function renderText(value) {
+function renderText(value, docPath) {
   const src = String(value);
   // Combined regex: wiki-link OR URL. The wiki-link branch wins when both
   // could match the same position because it's listed first in the alternation.
@@ -208,10 +522,19 @@ function renderText(value) {
   while ((m = TOKEN_RE.exec(src)) !== null) {
     out += escapeHtml(src.slice(lastIndex, m.index));
     if (m[1] !== undefined) {
-      // Wiki-link. Inner is raw content from the user.
+      // Wiki-link. Resolve it the same way the body mps_wikilink renderer
+      // does - parse the alias/fragment, resolve against the workspace index,
+      // build the href - so frontmatter wikilinks navigate and honour alias
+      // syntax instead of emitting a literal `name|alias` href.
       const inner = m[1];
-      const display = escapeHtml(basenameWithoutExt(inner));
-      const href = safeHref(inner);
+      const parsed = parseWikilinkTarget(inner);
+      const display = escapeHtml(parsed.alias != null
+        ? parsed.alias
+        : basenameWithoutExt(parsed.name || inner));
+      const resolved = wikiConfig.enabled ? resolveWikilinkTarget(parsed.name) : null;
+      const href = resolved
+        ? buildResolvedHref(resolved, parsed.fragment, docPath)
+        : safeHref((parsed.name || inner) + fragmentToAnchor(parsed.fragment));
       out += href
         ? `${display}`
         : `${display}`;
@@ -230,7 +553,7 @@ function renderText(value) {
   return out;
 }
 
-function renderValue(value, type) {
+function renderValue(value, type, docPath) {
   if (value === null || value === undefined || value === '' || (Array.isArray(value) && value.length === 0)) {
     return 'Empty';
   }
@@ -238,7 +561,7 @@ function renderValue(value, type) {
     return `
${value.map(v => `${escapeHtml(v)}`).join('')}
`; } if (type === 'list') { - return value.map(renderText).join(', '); + return value.map(v => renderText(v, docPath)).join(', '); } if (type === 'date' || type === 'datetime') { return `${formatDate(value)}`; @@ -246,10 +569,14 @@ function renderValue(value, type) { if (type === 'checkbox') { return value ? '' : ''; } - return renderText(value); + return renderText(value, docPath); } -function renderProperties(data) { +// docPath (previewed document's path, from docPathFromEnv) lets frontmatter +// wikilinks resolve to a document-relative href like body wikilinks do; it's +// undefined when called without a render env (the inner pieces fall back to +// the vscode://file form, which still navigates). +function renderProperties(data, docPath) { const entries = Object.entries(data); if (entries.length === 0) return ''; const rows = entries.map(([key, value]) => { @@ -257,13 +584,175 @@ function renderProperties(data) { const icon = ICONS[type] || ICONS.text; return `` + `${icon}${escapeHtml(key)}` - + `${renderValue(value, type)}` + + `${renderValue(value, type, docPath)}` + ``; }).join(''); return ``; } -function activate() { +// Build the workspace index from VS Code's workspace API. Returns an +// array of { vscode, watchers } so deactivate can dispose. No-op outside +// of a VS Code host (vscode require throws in unit tests / Node-only runs). +async function initWorkspaceIndex(context) { + let vscode; + try { + vscode = require('vscode'); + } catch (_) { + return; // Not running inside VS Code (unit test or harness). Skip. + } + + const config = vscode.workspace.getConfiguration('markdownPreviewStyles.wikilinks'); + wikiConfig = { + enabled: config.get('enabled', true), + extraIndexRoots: config.get('extraIndexRoots', []), + embedNotes: config.get('embedNotes', true), + embedMaxBytes: config.get('embedMaxBytes', 262144), + }; + + // Hot-reload when the user flips settings. + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('markdownPreviewStyles.wikilinks')) { + rebuildWorkspaceIndex(context, vscode).catch(err => + console.warn('markdown-preview-styles: index rebuild failed', err) + ); + } + }) + ); + + // Initial build. (rebuildWorkspaceIndex triggers a preview refresh on + // completion so previews opened before the index built pick up resolution.) + await rebuildWorkspaceIndex(context, vscode); + + // React to workspace folder add/remove without forcing a full reload. + context.subscriptions.push( + vscode.workspace.onDidChangeWorkspaceFolders(() => { + rebuildWorkspaceIndex(context, vscode).catch(err => + console.warn('markdown-preview-styles: index rebuild failed', err) + ); + }) + ); +} + +// Watchers from the previous rebuild - disposed before each fresh build. +let _activeWatchers = []; +// Monotonic rebuild counter. rebuildWorkspaceIndex is async with awaits +// (findFiles per root), so a config change firing during the initial build +// can interleave a second rebuild with the first. Each call captures the +// generation at entry and bails after every await if a newer rebuild has +// started - otherwise two rebuilds both add to the shared index and both push +// watchers, leaving duplicate live watchers (double index events) and orphans +// that never get disposed. +let _rebuildGeneration = 0; + +async function rebuildWorkspaceIndex(context, vscode) { + const myGen = ++_rebuildGeneration; + const superseded = () => myGen !== _rebuildGeneration; + + // Re-read config in case this rebuild was triggered by a config change. + const config = vscode.workspace.getConfiguration('markdownPreviewStyles.wikilinks'); + wikiConfig = { + enabled: config.get('enabled', true), + extraIndexRoots: config.get('extraIndexRoots', []), + embedNotes: config.get('embedNotes', true), + embedMaxBytes: config.get('embedMaxBytes', 262144), + }; + + // Tear down previous watchers. + for (const w of _activeWatchers) w.dispose(); + _activeWatchers = []; + + wikiIndex = new Map(); + wikiRoots = []; + + if (!wikiConfig.enabled) return; + + // Collect roots: workspace folders + extraIndexRoots, deduplicated by + // canonical absolute path. Missing extra roots are warned-and-skipped. + const seen = new Set(); + const roots = []; + for (const folder of (vscode.workspace.workspaceFolders || [])) { + const canonical = canonicalisePath(folder.uri.fsPath); + if (canonical && !seen.has(canonical)) { + seen.add(canonical); + roots.push({ absPath: folder.uri.fsPath, canonical }); + } + } + for (const raw of wikiConfig.extraIndexRoots) { + const expanded = expandTilde(raw); + const canonical = canonicalisePath(expanded); + if (!canonical) { + console.warn('markdown-preview-styles: extraIndexRoots path not found, skipping:', raw); + continue; + } + if (seen.has(canonical)) continue; + seen.add(canonical); + roots.push({ absPath: expanded, canonical }); + } + + wikiRoots = roots; + + // findFiles + watcher per root. Per-root patterns keep the watcher set + // explicit and let us tag each indexed path with its root's sort key. + // Watchers accumulate in a local array and are only committed to the shared + // _activeWatchers / context.subscriptions once we know this rebuild won the + // race; a superseded rebuild disposes them instead of leaking them. + const watchers = []; + for (const root of roots) { + const rootSortKey = root.canonical; + const pattern = new vscode.RelativePattern(root.absPath, '**/*.md'); + const exclude = '**/node_modules/**'; + try { + const uris = await vscode.workspace.findFiles(pattern, exclude); + if (superseded()) { for (const w of watchers) w.dispose(); return; } + for (const uri of uris) addToIndex(wikiIndex, uri.fsPath, rootSortKey); + } catch (err) { + console.warn('markdown-preview-styles: findFiles failed for root', root.absPath, err); + } + + const watcher = vscode.workspace.createFileSystemWatcher(pattern); + watcher.onDidCreate(uri => addToIndex(wikiIndex, uri.fsPath, rootSortKey)); + watcher.onDidDelete(uri => removeFromIndex(wikiIndex, uri.fsPath)); + // onDidChange is a no-op - content edits don't move the file. + watchers.push(watcher); + } + + // Commit this rebuild's watchers now that it owns the index. + _activeWatchers = watchers; + for (const w of watchers) context.subscriptions.push(w); + + // Refresh any already-open markdown previews. They may have rendered + // against an empty or stale index; the refresh re-runs the rules with + // the now-current wikiIndex/wikiConfig in scope. No-op when no previews + // are open. Wrapped because the command may be unavailable in some + // VS Code builds. + try { + await vscode.commands.executeCommand('markdown.preview.refresh'); + } catch (_) {} +} + +// Returns the canonical (realpath-resolved) absolute path, or null if the +// path doesn't exist or isn't accessible. Used to deduplicate symlinked or +// overlapping roots without crashing on missing extraIndexRoots entries. +function canonicalisePath(p) { + try { + return fs.realpathSync(p); + } catch (_) { + return null; + } +} + +function activate(context) { + // Async index build runs in the background. Rules registered by + // extendMarkdownIt close over wikiIndex/wikiConfig which start at sensible + // defaults and get replaced once findFiles completes. Any preview that + // rendered against the empty initial index gets a programmatic refresh + // when the build finishes (see rebuildWorkspaceIndex tail). + if (context && context.subscriptions) { + initWorkspaceIndex(context).catch(err => + console.warn('markdown-preview-styles: index init failed', err) + ); + } return { extendMarkdownIt(md) { // ![[path]] and ![[path|N]] - Obsidian-style image embeds. @@ -348,12 +837,24 @@ function activate() { altTok.content = altText; tok.children = [altTok]; } else { - // Non-image OR image-shaped-but-rejected-scheme. Degrade to a - // wiki-link. Mark with meta.embed so CSS / future code can - // distinguish a degraded embed from a plain [[wiki-link]]. - const tok = state.push('mps_wikilink', '', 0); - tok.content = path; - tok.meta = { embed: true }; + // Non-image OR image-shaped-but-rejected-scheme. Try transcluding + // (when enabled, target resolves, and size is under cap); otherwise + // degrade to a wiki-link with meta.embed for the fallback style. + const transcluded = tryEmitTranscludeToken(state, path); + if (!transcluded) { + const tok = state.push('mps_wikilink', '', 0); + tok.content = path; + // Resolve here too, so a target that simply wasn't transcluded + // (embedNotes off, or over the size cap) still gets a working + // href instead of degrading to a dead bare-name link. Mirrors + // the mps_wikilink parse rule's resolution. + const parsed = parseWikilinkTarget(path); + tok.meta = { embed: true, parsed }; + if (wikiConfig.enabled) { + const resolved = resolveWikilinkTarget(parsed.name); + if (resolved) tok.meta.resolvedPath = resolved; + } + } } } state.pos = end + 2; @@ -378,29 +879,190 @@ function activate() { if (!silent) { const token = state.push('mps_wikilink', '', 0); token.content = content; + // Parse + resolve at token-emit time so the renderer is a pure + // function over the token's meta. Resolution is skipped when the + // workspace index is disabled - the renderer then falls back to + // emitting `content` as a relative href (current behaviour). + const parsed = parseWikilinkTarget(content); + token.meta = token.meta || {}; + token.meta.parsed = parsed; + if (wikiConfig.enabled) { + const resolved = resolveWikilinkTarget(parsed.name); + if (resolved) { + token.meta.resolvedPath = resolved; + // Document URI lookup happens at RENDER time, not parse time. + // VS Code's inline rules run before `env` is populated with + // `currentDocument`, so probing state.env here returns an empty + // object. The renderer rule receives env as its 4th arg, and + // by that point currentDocument is available. + } + } } state.pos = end + 2; return true; }); - md.renderer.rules.mps_wikilink = function (tokens, idx) { + // Renderer for the inline transclude token emitted by mps_embed's + // non-image branch. Reads the resolved file, slices to the fragment, + // strips frontmatter, and re-renders via the same markdown-it + // instance with env.mpsEmbedDepth incremented. Depth >= 2 short- + // circuits to a cycle-fallback link (matches the depth-cap AC). + md.renderer.rules.mps_embed_note = function (tokens, idx, options, env, self) { + const tok = tokens[idx]; + const meta = tok.meta || {}; + const e = env || {}; + const depth = e.mpsEmbedDepth || 0; + // Every degrade path (cycle cap, fragment-miss, read error) renders the + // same fallback link: a real clickable href via buildResolvedHref (NOT + // the bare absolute resolvedPath, which VS Code can't navigate), and + // display text from the parsed target so an alias / fragment in the + // original `![[...]]` shows cleanly rather than literally. + const parsedTarget = parseWikilinkTarget(meta.originalTarget || ''); + const fallbackDisplay = parsedTarget.alias != null + ? parsedTarget.alias + : basenameWithoutExt(parsedTarget.name || meta.originalTarget || ''); + const fallbackLink = (extraClass) => { + const cls = 'mps-wiki-link mps-embed-fallback' + (extraClass ? ' ' + extraClass : ''); + const href = meta.resolvedPath + ? buildResolvedHref(meta.resolvedPath, meta.fragment, docPathFromEnv(e)) + : ''; + if (!href) return `${escapeHtml(fallbackDisplay)}`; + return `${escapeHtml(fallbackDisplay)}`; + }; + // Cycle-cap: at depth 2, refuse to expand. mps-embed-cycle marks it + // visually distinct from a fresh embed. + if (depth >= 2) return fallbackLink('mps-embed-cycle'); + let body = ''; + try { + const raw = wikiReadFile(meta.resolvedPath); + const stripped = stripFrontmatter(raw); + const sliced = sliceToFragment(stripped, meta.fragment); + if (!sliced) return fallbackLink(); // fragment not found in target + const innerEnv = Object.assign({}, e, { mpsEmbedDepth: depth + 1 }); + if (typeof md.render === 'function') { + body = md.render(sliced, innerEnv); + } else { + // Stub markdown-it in unit tests has no render(). Emit the raw + // sliced source so the wrapper assertion still passes; the real + // VS Code preview path uses the live md.render. + body = escapeHtml(sliced); + } + } catch (err) { + // Read failure (race with index, permissions). Fall back to a + // link rather than crashing the whole preview render. + return fallbackLink(); + } + return `
${body}
`; + }; + + md.renderer.rules.mps_wikilink = function (tokens, idx, options, env) { const tok = tokens[idx]; - const content = tok.content; - const display = basenameWithoutExt(content); - const href = safeHref(content); - // Tokens emitted by mps_embed for non-image embeds carry meta.embed - // so the rendered anchor can be styled distinctly (an attachment - // icon, dimmer treatment, etc). Without this signal the degraded - // embed renders identically to a plain [[wiki-link]] - the user - // gets no indication the embed didn't render inline. - const cls = tok.meta && tok.meta.embed - ? 'mps-wiki-link mps-embed-fallback' - : 'mps-wiki-link'; + const content = tok.content || ''; + // Derive parse lazily so the renderer keeps working when called + // directly (frontmatter renderText, unit tests) with only .content. + const parsed = (tok.meta && tok.meta.parsed) || parseWikilinkTarget(content); + // Display: alias if given; else the basename; else (pure fragment, no + // name) the fragment's own text - the heading/block id minus its + // leading marker - rather than the raw `#frag` content. + const display = parsed.alias != null + ? parsed.alias + : (parsed.name + ? basenameWithoutExt(parsed.name) + : (parsed.fragment ? parsed.fragment.slice(1) : content)); + // docPath (previewed document's path) is read from env at RENDER time - + // see docPathFromEnv for why parse-time isn't reliable and why both + // currentDocument and resourceProvider.resource are consulted. + const docPath = docPathFromEnv(env); + // Three href shapes, in priority order: + // - resolved target → buildResolvedHref (relative / vscode://file / + // bare-fragment self-link); resolver output is trusted, no safeHref. + // - no index hit → safeHref-guarded raw name (rejects dangerous + // schemes, preserves the pre-resolution document-relative behaviour). + let href; + const meta = tok.meta || {}; + if (meta.resolvedPath) { + href = buildResolvedHref(meta.resolvedPath, parsed.fragment, docPath); + } else if (parsed.name) { + // Unresolved named link: document-relative raw name + anchor. + href = safeHref(parsed.name + fragmentToAnchor(parsed.fragment)); + } else if (parsed.fragment) { + // Pure fragment ([[#heading]] / [[^block]]): same-document anchor, + // no name. Using `content` here would double the fragment (content + // already IS the fragment) and feed safeHref a leading-# string. + href = fragmentToAnchor(parsed.fragment); + } else { + href = safeHref(content); + } + // Tokens emitted by mps_embed for non-image embeds carry meta.embed so + // the rendered anchor can be styled distinctly. + const classes = ['mps-wiki-link']; + if (tok.meta && tok.meta.embed) classes.push('mps-embed-fallback'); + const cls = classes.join(' '); // Empty href falls back to the inert span - covers [[javascript:...]] // and similar dangerous schemes. if (!href) return `${escapeHtml(display)}`; return `${escapeHtml(display)}`; }; + // Block-ref anchors: `^block-id` at the end of a paragraph or list item + // creates a scroll target. Walks the token stream looking for inline + // tokens whose content ends with ` ^id`, strips the marker, and adds + // `id="mps-block-"` to the wrapping block-level open token. + // Runs unconditionally so any preview containing ^id markers gets the + // anchor regardless of whether a wikilink targets it. Must run before + // mps_callouts so a marker on a callout's first line still works. + md.core.ruler.push('mps_block_anchors', function (state) { + const tokens = state.tokens; + // Inside a transcluded embed, still strip the ^id marker (so the + // embedded text reads cleanly) but DON'T emit id="mps-block-" - + // the host document already carries that id (or will, if the note is + // also open standalone), and a duplicate id is invalid HTML with an + // ambiguous scroll target. + const embedded = !!(state.env && state.env.mpsEmbedDepth); + for (let i = 0; i < tokens.length; i++) { + const tok = tokens[i]; + if (tok.type !== 'inline' || !tok.content) continue; + // Trailing block marker: optional whitespace, then ^id (Crockford- + // safe alphanumerics + hyphen), then end of content. + const m = tok.content.match(/^([\s\S]*?)\s\^([A-Za-z0-9_-]+)\s*$/); + if (!m) continue; + // Find the wrapping block-level open token. Walk backwards from + // this inline token; the open is the most recent token whose + // .nesting === 1 at the same level - 1 of this inline (which sits + // at level = open.level + 1). For our purposes, scan back for the + // first open token whose tag is one of the carriers we recognise. + let openIdx = -1; + for (let j = i - 1; j >= 0; j--) { + const t = tokens[j]; + if (t.nesting === -1) continue; // skip close tokens + // Prefer list_item_open if it directly precedes; else fall back + // to paragraph_open / heading_open as the carrier. + if (t.type === 'paragraph_open' || t.type === 'heading_open') { + openIdx = j; + // Look one more step back: if the immediately preceding open + // is list_item_open at the same source line, hoist to it so + // the anchor lands on the
  • not the inner

    . + const prev = tokens[j - 1]; + if (prev && prev.type === 'list_item_open') openIdx = j - 1; + break; + } + if (t.type === 'list_item_open') { openIdx = j; break; } + if (t.nesting === 1) { openIdx = j; break; } + } + if (openIdx < 0) continue; + if (!embedded) tokens[openIdx].attrSet('id', 'mps-block-' + m[2]); + // Strip the marker from inline content and re-parse children if + // the inline parser is available (real markdown-it). Stub markdown-it + // in tests has no inline.parse, so leave children as-is - tests + // assert on .content, which is the load-bearing surface. + tok.content = m[1].replace(/\s+$/, ''); + if (state.md && state.md.inline && typeof state.md.inline.parse === 'function') { + const out = []; + state.md.inline.parse(tok.content, state.md, state.env || {}, out); + tok.children = out; + } + } + }); + // Obsidian-style callouts: `> [!type] Optional title` at the start of a // blockquote becomes a div with separated title + body. Has to run before // mps_blank_lines so the line-number rule sees the rewritten tag names. @@ -524,6 +1186,12 @@ function activate() { }); md.core.ruler.push('mps_blank_lines', function (state) { + // Skip inside a transcluded embed: the line numbers and blank-line + // placeholders are source-mapped to the EMBEDDED note's lines, not the + // host document's, so emitting them injects data-line/data-mps-line + // values that collide with the host's and mislead VS Code's active-line + // tracker / double-click-to-jump. Embedded content carries no gutter. + if (state.env && state.env.mpsEmbedDepth) return; const lines = (state.src || '').split(/\r?\n/); const result = []; let lastEnd = 0; @@ -581,7 +1249,7 @@ function activate() { try { const data = parseFrontmatter(match[1]); if (data['mps-hide'] === true) return; - html = renderProperties(data); + html = renderProperties(data, docPathFromEnv(state.env)); } catch (e) { html = `

    Failed to parse frontmatter: ${escapeHtml(e.message)}
    `; } @@ -596,3 +1264,13 @@ function activate() { } exports.activate = activate; +exports.parseWikilinkTarget = parseWikilinkTarget; +exports.resolveWikilinkTarget = resolveWikilinkTarget; +exports.addToIndex = addToIndex; +exports.removeFromIndex = removeFromIndex; +exports.expandTilde = expandTilde; +exports.__setWikiStateForTest = __setWikiStateForTest; +exports.__resetWikiStateForTest = __resetWikiStateForTest; +// Exported for the concurrency test (rebuild generation guard). Takes the +// same (context, vscode) it gets in production; a test passes a mock vscode. +exports.__rebuildWorkspaceIndexForTest = rebuildWorkspaceIndex; diff --git a/package.json b/package.json index bad778c..4561efb 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,32 @@ ], "markdown.previewScripts": [ "./preview.js" - ] + ], + "configuration": { + "title": "Markdown Preview Styles", + "properties": { + "markdownPreviewStyles.wikilinks.enabled": { + "type": "boolean", + "default": true, + "description": "Resolve [[wikilinks]] across the whole workspace. When off, wikilinks render with document-relative hrefs and no workspace index is built." + }, + "markdownPreviewStyles.wikilinks.extraIndexRoots": { + "type": "array", + "items": { "type": "string" }, + "default": [], + "description": "Additional folders to index for wikilink resolution. Useful when you keep notes outside the open workspace (e.g. a personal vault). Absolute paths or tilde-prefixed (~/Documents/notes). Missing paths are skipped with a warning." + }, + "markdownPreviewStyles.wikilinks.embedNotes": { + "type": "boolean", + "default": true, + "description": "Render ![[name]] for non-image targets as inline transclusions (the referenced note's body is rendered inline). When off, non-image embeds degrade to a wiki-link." + }, + "markdownPreviewStyles.wikilinks.embedMaxBytes": { + "type": "number", + "default": 262144, + "description": "Maximum file size (bytes) for an inline note transclusion. Larger targets degrade to a wiki-link to protect the preview from accidentally embedding huge files." + } + } + } } } diff --git a/preview.js b/preview.js index d976266..bb27d07 100644 --- a/preview.js +++ b/preview.js @@ -124,10 +124,12 @@ img.setAttribute('data-mps-original-src', img.getAttribute('src') || ''); let loaded = false; - img.addEventListener('load', () => { loaded = true; }); + let settled = false; + img.addEventListener('load', () => { loaded = true; settled = true; }); img.addEventListener('error', handleError); function handleError() { + settled = true; // Lost-race protection on top of the load-listener: if the load // event managed to fire AND the error somehow fires later (browsers // shouldn't, but defence in depth), don't degrade a working image. @@ -146,6 +148,7 @@ const alreadyInAttachments = lc.includes('/attachments/') || lc.startsWith('attachments/'); if (isBareFilename && !alreadyInAttachments) { img.setAttribute('src', 'attachments/' + currentSrc); + settled = false; // retry pending - allow load/error to fire again return; } } @@ -155,14 +158,22 @@ } // Race-guard for images that resolved BEFORE we attached listeners. - // `complete` is true once load OR error has been processed. See - // CLAUDE.md "naturalWidth === 0 is NOT a reliable broken-image signal" - // for why we don't check naturalWidth here. Instead, give the load - // listener one frame to fire; if `complete` is true and we still - // haven't seen a load event, the image errored before we wired up. + // Previous approach (frame-delay + `loaded` flag) misclassified cached + // images as broken: when VS Code's DOM diff cleared data-mps-wired on a + // still-loaded , the rewire attached fresh listeners after the + // browser had already fired `load`. complete=true + loaded=false then + // triggered a false handleError. + // + // img.decode() is the race-free check: resolves iff the browser can + // decode the current bitmap (i.e. loaded successfully), rejects on any + // decode/network failure. Works identically for cache-hit and fresh- + // fetch, so the rewire path is now safe. if (img.complete) { - requestAnimationFrame(() => { - if (!loaded) handleError(); + img.decode().then(() => { + loaded = true; + settled = true; + }).catch(() => { + if (!settled) handleError(); }); } } diff --git a/style.css b/style.css index ff3ba8f..f699de7 100644 --- a/style.css +++ b/style.css @@ -600,6 +600,33 @@ body .mps-embed-fallback::before { opacity: 0.7; } +/* Inline note transclusion: ![[name]] for a non-image target. The renderer + wraps the embedded body in mps-embed-note > mps-embed-note-body so the + container can carry a left-edge indicator and the inner body keeps its + default block margins from cascading to the surrounding document. */ +body .mps-embed-note { + margin: 0.75em 0; + padding: 0.5em 0 0.5em 1em; + border-left: 3px solid var(--vscode-editorWidget-border, rgba(128, 128, 128, 0.3)); + background: var(--vscode-editorWidget-background, rgba(128, 128, 128, 0.05)); + border-radius: 0 4px 4px 0; +} + +body .mps-embed-note-body > :first-child { margin-top: 0; } +body .mps-embed-note-body > :last-child { margin-bottom: 0; } + +/* Cycle-broken embed: ![[name]] expanded past depth 2 - render as a link + but mark visually so the user knows the inline expansion didn't happen + for safety reasons rather than because the target was missing. */ +body .mps-embed-cycle { + opacity: 0.7; + font-style: italic; +} + +/* Block-ref anchors: extension.js's mps_block_anchors core rule adds + id="mps-block-" to paragraphs/list-items carrying a trailing ^id. + No visual treatment by default - the id is purely a scroll target. */ + .mps-check.on { color: var(--vscode-charts-green, #4caf50); } .mps-check.off { opacity: 0.4; } diff --git a/test/test.js b/test/test.js index 23dca62..9e17f24 100644 --- a/test/test.js +++ b/test/test.js @@ -3,7 +3,16 @@ // markdown-it instance - no real markdown-it dependency. const assert = require('assert'); -const { activate } = require('../extension.js'); +const { + activate, + parseWikilinkTarget, + resolveWikilinkTarget, + addToIndex, + removeFromIndex, + __setWikiStateForTest, + __resetWikiStateForTest, + __rebuildWorkspaceIndexForTest, +} = require('../extension.js'); // ---- Stub markdown-it ------------------------------------------------------- @@ -60,8 +69,8 @@ function makeMd() { renderer: { rules: {} }, _coreRules: coreRules, _inlineRules: inlineRules, - runCore(src, tokens = []) { - const state = { src, tokens, Token: StubToken }; + runCore(src, tokens = [], env = undefined) { + const state = { src, tokens, Token: StubToken, env }; for (const r of coreRules) r.fn(state); return state; }, @@ -112,6 +121,27 @@ function test(name, fn) { } } +// Async tests register a thunk here; runAsyncTests() awaits them before the +// summary. Kept separate from the synchronous `test` so the bulk of the suite +// stays simple. +const asyncTests = []; +function testAsync(name, fn) { + asyncTests.push(async () => { + try { + await fn(); + console.log(' PASS ' + name); + passed++; + } catch (e) { + console.log(' FAIL ' + name); + console.log(' ' + (e.message || e)); + failed++; + } + }); +} +async function runAsyncTests() { + for (const t of asyncTests) await t(); +} + // ---- Frontmatter parser ----------------------------------------------------- console.log('\nFrontmatter parser:'); @@ -184,10 +214,39 @@ test('URLs in string values are linkified', () => { test('wiki-link and URL coexist in the same string value', () => { const html = renderHtml('---\ndesc: see [[parent]] or https://example.com\n---'); + // Unresolved (empty index) → document-relative raw name, unchanged behaviour. assert.match(html, /parent<\/a>/); assert.match(html, //); }); +// Frontmatter wikilinks resolve against the workspace index and honour alias +// syntax, the same as body wikilinks - previously they emitted a literal +// `href="name|alias"` and never resolved. (renderHtml runs with no env, so the +// resolved href takes the vscode://file form; with a render env it would be +// document-relative - covered by the body-renderer env tests.) +test('frontmatter wikilink resolves against the index (Properties value)', () => { + __setWikiStateForTest({ index: (() => { const i = new Map(); addToIndex(i, '/root/notes/parent.md', ''); return i; })(), config: { enabled: true } }); + try { + const html = renderHtml('---\nrelated: [[parent]]\n---'); + assert.match(html, /href="vscode:\/\/file\/root\/notes\/parent\.md"/); + assert.match(html, />parent { + __setWikiStateForTest({ index: (() => { const i = new Map(); addToIndex(i, '/root/notes/meeting.md', ''); return i; })(), config: { enabled: true } }); + try { + const html = renderHtml('---\nparent: [[meeting|Weekly sync]]\n---'); + assert.match(html, />Weekly sync { const html = renderHtml('---\ncreated: 2026-05-07\n---'); assert.match(html, /07\/05\/2026/); @@ -379,6 +438,23 @@ test('blank source lines between tokens get placeholder html_block tokens', () = assert.match(placeholders[1].content, /data-mps-line="3"/); }); +test('mps_blank_lines is skipped inside a transcluded embed (no placeholders, no data-mps-line)', () => { + const md = makeMd(); + activate().extendMarkdownIt(md); + const tokens = [ + makeToken({ map: [0, 1], type: 'paragraph_open' }), + makeToken({ map: [3, 4], type: 'paragraph_open' }), + ]; + // Embedded render: env carries mpsEmbedDepth. + const state = md.runCore('a\n\n\nb', tokens, { mpsEmbedDepth: 1 }); + const placeholders = state.tokens.filter( + t => typeof t.content === 'string' && t.content.includes('mps-blank-line') + ); + assert.strictEqual(placeholders.length, 0, 'no blank-line placeholders inside an embed'); + // And no data-mps-line stamped on the host-colliding tokens. + assert.strictEqual(getTokenAttr(tokens[0], 'data-mps-line'), undefined); +}); + test('non-blank lines between tokens do NOT get placeholders', () => { const md = makeMd(); activate().extendMarkdownIt(md); @@ -749,7 +825,826 @@ test('stub before() throws when target rule is not registered', () => { ); }); +// ---- Wikilink target parser ------------------------------------------------- + +console.log('\nWikilink target parser:'); + +test('parseWikilinkTarget: bare name', () => { + assert.deepStrictEqual(parseWikilinkTarget('foo'), { name: 'foo', fragment: null, alias: null }); +}); + +test('parseWikilinkTarget: name with heading fragment', () => { + assert.deepStrictEqual(parseWikilinkTarget('foo#bar'), { name: 'foo', fragment: '#bar', alias: null }); +}); + +test('parseWikilinkTarget: name with block fragment', () => { + assert.deepStrictEqual(parseWikilinkTarget('foo^bar'), { name: 'foo', fragment: '^bar', alias: null }); +}); + +test('parseWikilinkTarget: name with alias', () => { + assert.deepStrictEqual(parseWikilinkTarget('foo|display'), { name: 'foo', fragment: null, alias: 'display' }); +}); + +test('parseWikilinkTarget: canonical fragment-before-pipe (heading + alias)', () => { + assert.deepStrictEqual(parseWikilinkTarget('foo#bar|display'), { name: 'foo', fragment: '#bar', alias: 'display' }); +}); + +test('parseWikilinkTarget: canonical fragment-before-pipe (block + alias)', () => { + assert.deepStrictEqual(parseWikilinkTarget('foo^bar|display'), { name: 'foo', fragment: '^bar', alias: 'display' }); +}); + +test('parseWikilinkTarget: reverse-order (pipe-then-#) keeps # in alias verbatim', () => { + // Documents the non-support: `foo|display#bar` is NOT parsed as fragment. + // The pipe wins; everything to its right is the alias literally. + assert.deepStrictEqual(parseWikilinkTarget('foo|display#bar'), { name: 'foo', fragment: null, alias: 'display#bar' }); +}); + +test('parseWikilinkTarget: empty input', () => { + assert.deepStrictEqual(parseWikilinkTarget(''), { name: '', fragment: null, alias: null }); +}); + +test('parseWikilinkTarget: non-string returns empty parse', () => { + assert.deepStrictEqual(parseWikilinkTarget(null), { name: '', fragment: null, alias: null }); + assert.deepStrictEqual(parseWikilinkTarget(undefined), { name: '', fragment: null, alias: null }); +}); + +// #6: a ^block fragment is only recognised when the id matches the canonical +// block-id charset ([A-Za-z0-9_-]). A space or dot means it's NOT a valid +// block ref, so it's left in the name rather than half-parsed into an id the +// anchor can never match. +test('parseWikilinkTarget: ^block with a space is not a block fragment', () => { + assert.deepStrictEqual(parseWikilinkTarget('note^a b'), { name: 'note^a b', fragment: null, alias: null }); +}); + +test('parseWikilinkTarget: ^block with a dot is not a block fragment', () => { + assert.deepStrictEqual(parseWikilinkTarget('note^v1.2'), { name: 'note^v1.2', fragment: null, alias: null }); +}); + +test('parseWikilinkTarget: ^block with valid id chars is a block fragment', () => { + assert.deepStrictEqual(parseWikilinkTarget('note^blk_1-a'), { name: 'note', fragment: '^blk_1-a', alias: null }); +}); + +// #8: a pure-fragment target ([[#heading]] / [[^block]]) has an empty name - +// a same-document link, the way Obsidian treats it. +test('parseWikilinkTarget: pure heading fragment has empty name', () => { + assert.deepStrictEqual(parseWikilinkTarget('#Section A'), { name: '', fragment: '#Section A', alias: null }); +}); + +test('parseWikilinkTarget: pure block fragment has empty name', () => { + assert.deepStrictEqual(parseWikilinkTarget('^blk1'), { name: '', fragment: '^blk1', alias: null }); +}); + +// #5: fragmentToAnchor's heading slug matches VS Code's GithubSlugifier, so +// the href anchor lines up with the rendered heading id - including Unicode +// letters (kept) and consecutive whitespace (each char → a hyphen). +test('mps_wikilink heading anchor keeps Unicode letters (GitHub slug parity)', () => { + withIndex([{ absPath: '/root/notes/note.md' }], () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('[[note#Café]]'); + const env = { currentDocument: { fsPath: '/root/docs/cur.md' } }; + const html = md.renderer.rules.mps_wikilink(tokens, 0, {}, env); + // café, not caf - the é is preserved so the anchor matches the rendered + // heading id (which VS Code/GitHub also slugs to literal "café"). + assert.match(html, /#café"/); + }); +}); + +test('mps_wikilink heading anchor replaces each whitespace char (a b -> a--b)', () => { + withIndex([{ absPath: '/root/notes/note.md' }], () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('[[note#A B]]'); // two spaces + const env = { currentDocument: { fsPath: '/root/docs/cur.md' } }; + const html = md.renderer.rules.mps_wikilink(tokens, 0, {}, env); + assert.match(html, /#a--b"/); + }); +}); + +// ---- Wikilink resolver ------------------------------------------------------ + +console.log('\nWikilink resolver:'); + +// Helper: build a test index from a list of {absPath, rootSortKey} entries. +function makeIndex(entries) { + const idx = new Map(); + const { addToIndex } = require('../extension.js'); + for (const e of entries) addToIndex(idx, e.absPath, e.rootSortKey || ''); + return idx; +} + +test('resolveWikilinkTarget: hit on single match', () => { + const idx = makeIndex([{ absPath: '/root/notes/foo.md' }]); + assert.strictEqual(resolveWikilinkTarget('foo', idx), '/root/notes/foo.md'); +}); + +test('resolveWikilinkTarget: miss returns null', () => { + const idx = makeIndex([{ absPath: '/root/notes/foo.md' }]); + assert.strictEqual(resolveWikilinkTarget('bar', idx), null); +}); + +test('resolveWikilinkTarget: case-insensitive on basename', () => { + const idx = makeIndex([{ absPath: '/root/Notes/Foo.md' }]); + assert.strictEqual(resolveWikilinkTarget('foo', idx), '/root/Notes/Foo.md'); + assert.strictEqual(resolveWikilinkTarget('FOO', idx), '/root/Notes/Foo.md'); + assert.strictEqual(resolveWikilinkTarget('FoO', idx), '/root/Notes/Foo.md'); +}); + +test('resolveWikilinkTarget: shortest-path tiebreak within single root', () => { + const idx = makeIndex([ + { absPath: '/root/a/b/c/foo.md' }, + { absPath: '/root/foo.md' }, + { absPath: '/root/a/foo.md' }, + ]); + assert.strictEqual(resolveWikilinkTarget('foo', idx), '/root/foo.md'); +}); + +test('resolveWikilinkTarget: alphabetical tiebreak when paths are same depth', () => { + const idx = makeIndex([ + { absPath: '/root/zeta/foo.md' }, + { absPath: '/root/alpha/foo.md' }, + { absPath: '/root/beta/foo.md' }, + ]); + assert.strictEqual(resolveWikilinkTarget('foo', idx), '/root/alpha/foo.md'); +}); + +test('resolveWikilinkTarget: tolerates trailing .md in the wikilink', () => { + const idx = makeIndex([{ absPath: '/root/foo.md' }]); + assert.strictEqual(resolveWikilinkTarget('foo.md', idx), '/root/foo.md'); +}); + +test('resolveWikilinkTarget: tolerates folder prefix in the wikilink', () => { + // `[[some/foo]]` is the Foam/Dendron path-prefix form. We resolve by + // basename only - the prefix is hint, not constraint. + const idx = makeIndex([{ absPath: '/root/notes/foo.md' }]); + assert.strictEqual(resolveWikilinkTarget('some/foo', idx), '/root/notes/foo.md'); +}); + +test('resolveWikilinkTarget: empty target returns null', () => { + const idx = makeIndex([{ absPath: '/root/foo.md' }]); + assert.strictEqual(resolveWikilinkTarget('', idx), null); +}); + +test('resolveWikilinkTarget: cross-root ordering by rootSortKey then path', () => { + // Two roots: /a/ comes before /z/ alphabetically. Both contain foo.md. + // /a/deep/nested/foo.md should still beat /z/foo.md because rootSortKey + // dominates - shortest-path only matters within one root. + const idx = makeIndex([ + { absPath: '/z/foo.md', rootSortKey: '/z' }, + { absPath: '/a/deep/nested/foo.md', rootSortKey: '/a' }, + ]); + assert.strictEqual(resolveWikilinkTarget('foo', idx), '/a/deep/nested/foo.md'); +}); + +// ---- Index machinery -------------------------------------------------------- + +console.log('\nIndex add/remove:'); + +test('addToIndex stores by lowercase basename', () => { + const { addToIndex } = require('../extension.js'); + const idx = new Map(); + addToIndex(idx, '/root/Foo.md', ''); + assert.ok(idx.has('foo')); + assert.ok(!idx.has('Foo')); +}); + +test('addToIndex deduplicates the same absPath', () => { + const { addToIndex } = require('../extension.js'); + const idx = new Map(); + addToIndex(idx, '/root/foo.md', ''); + addToIndex(idx, '/root/foo.md', ''); + assert.strictEqual(idx.get('foo').length, 1); +}); + +test('removeFromIndex drops the entry and the bucket when empty', () => { + const { addToIndex, removeFromIndex } = require('../extension.js'); + const idx = new Map(); + addToIndex(idx, '/root/foo.md', ''); + removeFromIndex(idx, '/root/foo.md'); + assert.strictEqual(idx.has('foo'), false); +}); + +test('removeFromIndex preserves other entries in the same bucket', () => { + const { addToIndex, removeFromIndex } = require('../extension.js'); + const idx = new Map(); + addToIndex(idx, '/root/a/foo.md', ''); + addToIndex(idx, '/root/b/foo.md', ''); + removeFromIndex(idx, '/root/a/foo.md'); + assert.strictEqual(idx.get('foo').length, 1); + assert.strictEqual(idx.get('foo')[0].absPath, '/root/b/foo.md'); +}); + +// ---- mps_wikilink with workspace resolution --------------------------------- + +console.log('\nWiki-link rule with workspace resolution:'); + +function withIndex(entries, fn) { + const idx = new Map(); + for (const e of entries) addToIndex(idx, e.absPath, e.rootSortKey || ''); + __setWikiStateForTest({ index: idx, config: { enabled: true } }); + try { + return fn(); + } finally { + __resetWikiStateForTest(); + } +} + +test('mps_wikilink rule resolves [[name]] against the index and emits resolved href', () => { + withIndex([{ absPath: '/root/notes/foo.md' }], () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('[[foo]]'); + assert.strictEqual(tokens.length, 1); + assert.strictEqual(tokens[0].type, 'mps_wikilink'); + assert.ok(tokens[0].meta, 'token has meta'); + assert.strictEqual(tokens[0].meta.resolvedPath, '/root/notes/foo.md'); + }); +}); + +// VS Code's preview passes env.currentDocument as a vscode.Uri (verified +// against the 1.122 bundle: `currentDocument: typeof e == "string" ? void 0 +// : e.uri`). A Uri exposes `.fsPath` directly - it has NO `.uri` property. +// When the previewed document's path is known, the renderer must emit a +// path RELATIVE to that document, schemeless, so VS Code's webview click +// handler posts an `openLink` message (native in-preview navigation, no OS +// prompt) instead of falling back to the `vscode://file/...` URI (which +// triggers an OS prompt and opens the raw editor). +test('mps_wikilink renderer emits a document-relative href when currentDocument is a Uri (fsPath)', () => { + withIndex([{ absPath: '/root/notes/foo.md' }], () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('[[foo]]'); + // Uri shape: .fsPath present, NO .uri. This is the live VS Code env. + const env = { currentDocument: { fsPath: '/root/docs/current.md' } }; + const html = md.renderer.rules.mps_wikilink(tokens, 0, {}, env); + // Relative from /root/docs to /root/notes/foo.md is ../notes/foo.md. + assert.match(html, /href="\.\.\/notes\/foo\.md"/); + // Crucially NOT the vscode:// fallback - that's the OS-prompt path. + assert.doesNotMatch(html, /vscode:/); + }); +}); + +// Older builds / a future shape change may pass a TextDocument (with .uri.fsPath). +// The renderer tolerates both so the relative-path behaviour survives either way. +test('mps_wikilink renderer also reads currentDocument.uri.fsPath (TextDocument shape)', () => { + withIndex([{ absPath: '/root/notes/foo.md' }], () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('[[foo]]'); + const env = { currentDocument: { uri: { fsPath: '/root/docs/current.md' } } }; + const html = md.renderer.rules.mps_wikilink(tokens, 0, {}, env); + assert.match(html, /href="\.\.\/notes\/foo\.md"/); + assert.doesNotMatch(html, /vscode:/); + }); +}); + +// A heading fragment must ride along on the relative href so the embedded +// anchor still scrolls to the right place after navigation. +test('mps_wikilink renderer appends fragment to the document-relative href', () => { + withIndex([{ absPath: '/root/notes/foo.md' }], () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('[[foo#Section A]]'); + const env = { currentDocument: { fsPath: '/root/docs/current.md' } }; + const html = md.renderer.rules.mps_wikilink(tokens, 0, {}, env); + assert.match(html, /href="\.\.\/notes\/foo\.md#section-a"/); + assert.doesNotMatch(html, /vscode:/); + }); +}); + +// The incremental live-edit path (typing into a preview-to-the-side) calls +// VS Code's MarkdownEngine.render with the document TEXT (a string), so +// env.currentDocument is undefined. env.resourceProvider (the MarkdownPreview +// instance, passed on every render path) exposes `.resource` - the previewed +// document's Uri - so docPath survives. Without this, the relative href +// reverted to the vscode://file fallback after every keystroke. +test('mps_wikilink renderer reads env.resourceProvider.resource when currentDocument is absent (incremental edit path)', () => { + withIndex([{ absPath: '/root/notes/foo.md' }], () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('[[foo]]'); + // Incremental render env: no currentDocument, resourceProvider has .resource. + const env = { resourceProvider: { resource: { fsPath: '/root/docs/current.md' } } }; + const html = md.renderer.rules.mps_wikilink(tokens, 0, {}, env); + assert.match(html, /href="\.\.\/notes\/foo\.md"/); + assert.doesNotMatch(html, /vscode:/); + }); +}); + +// currentDocument wins over resourceProvider when both are present (full +// render path) - they agree in practice, but pin the precedence. +test('mps_wikilink renderer prefers currentDocument over resourceProvider when both present', () => { + withIndex([{ absPath: '/root/notes/foo.md' }], () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('[[foo]]'); + const env = { + currentDocument: { fsPath: '/root/docs/current.md' }, + resourceProvider: { resource: { fsPath: '/elsewhere/other.md' } }, + }; + const html = md.renderer.rules.mps_wikilink(tokens, 0, {}, env); + // Relative from /root/docs (currentDocument), not /elsewhere. + assert.match(html, /href="\.\.\/notes\/foo\.md"/); + }); +}); + +// No docPath available from ANY source (truly empty env) → fall back to the +// vscode://file URI so the link still works cross-path, just with the +// OS-prompt friction. +test('mps_wikilink renderer falls back to vscode://file when no docPath in env', () => { + withIndex([{ absPath: '/root/notes/foo.md' }], () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('[[foo]]'); + const html = md.renderer.rules.mps_wikilink(tokens, 0, {}, {}); + assert.match(html, /href="vscode:\/\/file\/root\/notes\/foo\.md"/); + }); +}); + +test('mps_wikilink rule renders [[name|alias]] with alias as display text', () => { + withIndex([{ absPath: '/root/foo.md' }], () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('[[foo|My display]]'); + const html = md.renderer.rules.mps_wikilink(tokens, 0); + assert.match(html, />My display { + withIndex([{ absPath: '/root/foo.md' }], () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('[[foo#Section A]]'); + const html = md.renderer.rules.mps_wikilink(tokens, 0); + // Slugified heading appears in the href. The slug strategy is "lowercase, + // replace whitespace with -" - matches markdown-it's default header anchor. + assert.match(html, /href="[^"]*foo\.md#section-a"/); + }); +}); + +test('mps_wikilink rule renders [[name^block]] with #mps-block- in href', () => { + withIndex([{ absPath: '/root/foo.md' }], () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('[[foo^xyz123]]'); + const html = md.renderer.rules.mps_wikilink(tokens, 0); + assert.match(html, /href="[^"]*foo\.md#mps-block-xyz123"/); + }); +}); + +test('mps_wikilink rule combined: [[name#heading|alias]] keeps alias for display, heading in href', () => { + withIndex([{ absPath: '/root/foo.md' }], () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('[[foo#Section A|Pretty name]]'); + const html = md.renderer.rules.mps_wikilink(tokens, 0); + assert.match(html, />Pretty name { + withIndex([{ absPath: '/root/foo.md' }], () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('[[foo|My display#Section]]'); + const html = md.renderer.rules.mps_wikilink(tokens, 0); + // The pipe wins; #Section is part of the alias display text. + assert.match(html, />My display#Section { + withIndex([], () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('[[never-existed]]'); + const html = md.renderer.rules.mps_wikilink(tokens, 0); + // Per ticket AC: "failed resolution renders an inert span exactly as today". + // Today's behaviour is actually an with the relative path - VS Code's + // webview link handler then fails to navigate. We preserve that: when there + // is NO workspace index hit, we keep the document-relative href so the + // existing behaviour is unchanged in workspaces that don't use the index. + // The "inert span" is reserved for dangerous-scheme rejection. + assert.match(html, /class="mps-wiki-link"/); + assert.match(html, />never-existed { + withIndex([], () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('[[#Section Two]]'); + const html = md.renderer.rules.mps_wikilink(tokens, 0); + assert.match(html, /href="#section-two"/); + assert.doesNotMatch(html, /#section-two#/); // not doubled + assert.match(html, />Section Two { + withIndex([], () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('[[^my-block]]'); + const html = md.renderer.rules.mps_wikilink(tokens, 0); + assert.match(html, /href="#mps-block-my-block"/); + assert.match(html, />my-block { + withIndex([{ absPath: '/root/foo.md' }], () => { + __setWikiStateForTest({ config: { enabled: false } }); + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('[[foo]]'); + // When disabled, the rule should still parse the inner content but skip + // the resolver. resolvedPath is null/undefined; href is the raw inner. + assert.strictEqual(tokens[0].meta && tokens[0].meta.resolvedPath, undefined); + const html = md.renderer.rules.mps_wikilink(tokens, 0); + assert.match(html, /href="foo"/); + }); +}); + +test('mps_wikilink rule: javascript: scheme still rejected via safeHref', () => { + withIndex([], () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('[[javascript:alert(1)]]'); + const html = md.renderer.rules.mps_wikilink(tokens, 0); + assert.doesNotMatch(html, /href=/); + assert.match(html, //); + }); +}); + +// ---- Block-anchor core rule (mps_block_anchors) ----------------------------- + +console.log('\nBlock anchor rule:'); + +test('mps_block_anchors: trailing ^id on a paragraph sets id on paragraph_open and strips marker', () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = [ + makeToken({ type: 'paragraph_open', tag: 'p', map: [0, 1] }), + makeInline('some text ^xyz123'), + makeToken({ type: 'paragraph_close', tag: 'p' }), + ]; + md.runCore('some text ^xyz123', tokens); + assert.strictEqual(getAttr(tokens[0], 'id'), 'mps-block-xyz123'); + assert.strictEqual(tokens[1].content, 'some text', 'marker stripped from inline content'); +}); + +test('mps_block_anchors: ^id in the middle is NOT a marker (only end-of-block)', () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = [ + makeToken({ type: 'paragraph_open', tag: 'p', map: [0, 1] }), + makeInline('some ^xyz text continues'), + makeToken({ type: 'paragraph_close', tag: 'p' }), + ]; + md.runCore('', tokens); + assert.strictEqual(getAttr(tokens[0], 'id'), undefined); + assert.strictEqual(tokens[1].content, 'some ^xyz text continues'); +}); + +test('mps_block_anchors inside an embed strips the marker but does NOT set a duplicate id', () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = [ + makeToken({ type: 'paragraph_open', tag: 'p', map: [0, 1] }), + makeInline('some text ^xyz123'), + makeToken({ type: 'paragraph_close', tag: 'p' }), + ]; + // Embedded render: env carries mpsEmbedDepth. + md.runCore('some text ^xyz123', tokens, { mpsEmbedDepth: 1 }); + assert.strictEqual(getAttr(tokens[0], 'id'), undefined, 'no id inside an embed (would duplicate the host)'); + assert.strictEqual(tokens[1].content, 'some text', 'marker still stripped for clean text'); +}); + +test('mps_block_anchors: trailing ^id on a list item sets id on list_item_open', () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = [ + makeToken({ type: 'list_item_open', tag: 'li', map: [0, 1] }), + makeToken({ type: 'paragraph_open', tag: 'p', map: [0, 1] }), + makeInline('item text ^abc'), + makeToken({ type: 'paragraph_close', tag: 'p' }), + makeToken({ type: 'list_item_close', tag: 'li' }), + ]; + md.runCore('', tokens); + assert.strictEqual(getAttr(tokens[0], 'id'), 'mps-block-abc', 'id on li, not p'); + assert.strictEqual(tokens[2].content, 'item text'); +}); + +test('mps_block_anchors: paragraphs without ^id markers are untouched', () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = [ + makeToken({ type: 'paragraph_open', tag: 'p', map: [0, 1] }), + makeInline('plain text'), + makeToken({ type: 'paragraph_close', tag: 'p' }), + ]; + md.runCore('', tokens); + assert.strictEqual(getAttr(tokens[0], 'id'), undefined); + assert.strictEqual(tokens[1].content, 'plain text'); +}); + +// ---- Note transclusion (![[note]]) ------------------------------------------ + +console.log('\nNote transclusion:'); + +// Helpers to drive transclusion paths without touching disk. The extension +// uses injectable readFile/statFile via __setWikiStateForTest. +function withTranscludeFixtures(files, fn) { + const idx = new Map(); + for (const file of Object.keys(files)) addToIndex(idx, file, ''); + const readFile = (absPath) => { + if (!(absPath in files)) throw new Error('ENOENT: ' + absPath); + return files[absPath]; + }; + const statFile = (absPath) => { + if (!(absPath in files)) throw new Error('ENOENT: ' + absPath); + return { size: Buffer.byteLength(files[absPath], 'utf8') }; + }; + __setWikiStateForTest({ index: idx, config: { enabled: true, embedNotes: true, embedMaxBytes: 262144 }, readFile, statFile }); + try { + return fn(); + } finally { + __resetWikiStateForTest(); + } +} + +test('![[name]] for an indexed .md target emits a transclude token, not an image', () => { + withTranscludeFixtures({ '/root/foo.md': '# Foo\n\nbody' }, () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('![[foo]]'); + assert.strictEqual(tokens.length, 1); + assert.strictEqual(tokens[0].type, 'mps_embed_note'); + assert.strictEqual(tokens[0].meta.resolvedPath, '/root/foo.md'); + }); +}); + +test('![[name]] when embedNotes=false degrades to mps_wikilink with mps-embed-fallback', () => { + withTranscludeFixtures({ '/root/foo.md': '# Foo' }, () => { + __setWikiStateForTest({ config: { embedNotes: false } }); + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('![[foo]]'); + assert.strictEqual(tokens[0].type, 'mps_wikilink'); + assert.ok(tokens[0].meta && tokens[0].meta.embed); + }); +}); + +test('![[name]] when target unresolved degrades to mps_wikilink with mps-embed-fallback', () => { + withTranscludeFixtures({}, () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('![[not-in-index]]'); + assert.strictEqual(tokens[0].type, 'mps_wikilink'); + assert.ok(tokens[0].meta && tokens[0].meta.embed); + }); +}); + +test('![[name]] for an oversized target degrades to fallback', () => { + const big = 'x'.repeat(300000); // > default 262144 + withTranscludeFixtures({ '/root/foo.md': big }, () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('![[foo]]'); + assert.strictEqual(tokens[0].type, 'mps_wikilink'); + assert.ok(tokens[0].meta && tokens[0].meta.embed); + }); +}); + +test('![[image.png]] unchanged - still image token, not transclude', () => { + withTranscludeFixtures({}, () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('![[pic.png]]'); + assert.strictEqual(tokens[0].type, 'image'); + }); +}); + +test('mps_embed_note renderer wraps content in mps-embed-note container', () => { + withTranscludeFixtures({ '/root/foo.md': '# Foo\n\nbody text' }, () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('![[foo]]'); + const html = md.renderer.rules.mps_embed_note(tokens, 0); + assert.match(html, /class="mps-embed-note"/); + assert.match(html, /data-source="\/root\/foo\.md"/); + assert.match(html, /class="mps-embed-note-body"/); + }); +}); + +// ---- Embed / fallback href construction ------------------------------------- + +console.log('\nEmbed fallback hrefs:'); + +const RENDER_ENV = { currentDocument: { fsPath: '/root/docs/current.md' } }; + +// #1: the three mps_embed_note degrade paths (cycle cap, fragment-miss, read +// error) must emit a clickable href via buildResolvedHref - a path relative to +// the previewed document - NOT the bare absolute resolvedPath that VS Code +// can't navigate (it concatenates onto the preview dir -> ENOENT). +test('mps_embed_note cycle-cap fallback emits a document-relative href, not a bare absolute path', () => { + withTranscludeFixtures({ '/root/notes/foo.md': '# Foo\n\nbody' }, () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('![[foo]]'); + // Force the cycle cap by rendering at depth 2. + const env = Object.assign({ mpsEmbedDepth: 2 }, RENDER_ENV); + const html = md.renderer.rules.mps_embed_note(tokens, 0, {}, env); + assert.match(html, /class="[^"]*mps-embed-cycle[^"]*"/); + assert.match(html, /href="\.\.\/notes\/foo\.md"/); + assert.doesNotMatch(html, /href="\/root/); // not the bare absolute path + }); +}); + +test('mps_embed_note read-error fallback emits a document-relative href, not a bare absolute path', () => { + // Target is indexed (so resolvedPath is set) but readFile throws (not in + // the fixtures map) -> the catch fallback fires. + const idx = new Map(); + addToIndex(idx, '/root/notes/foo.md', ''); + __setWikiStateForTest({ + index: idx, + config: { enabled: true, embedNotes: true, embedMaxBytes: 262144 }, + readFile: () => { throw new Error('ENOENT'); }, + statFile: () => ({ size: 10 }), + }); + try { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('![[foo]]'); + const html = md.renderer.rules.mps_embed_note(tokens, 0, {}, RENDER_ENV); + assert.match(html, /href="\.\.\/notes\/foo\.md"/); + assert.doesNotMatch(html, /href="\/root/); + } finally { + __resetWikiStateForTest(); + } +}); + +// #12: fallback display text comes from the parsed target, so an alias shows +// cleanly instead of the literal "name|alias". +test('mps_embed_note fallback shows the alias, not the raw name|alias label', () => { + const idx = new Map(); + addToIndex(idx, '/root/notes/foo.md', ''); + __setWikiStateForTest({ + index: idx, + config: { enabled: true, embedNotes: true, embedMaxBytes: 262144 }, + readFile: () => { throw new Error('ENOENT'); }, + statFile: () => ({ size: 10 }), + }); + try { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('![[foo|Nice Alias]]'); + const html = md.renderer.rules.mps_embed_note(tokens, 0, {}, RENDER_ENV); + assert.match(html, />Nice Alias { + withTranscludeFixtures({ '/root/notes/foo.md': '# Foo' }, () => { + __setWikiStateForTest({ config: { embedNotes: false } }); + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('![[foo]]'); + assert.strictEqual(tokens[0].type, 'mps_wikilink'); + assert.strictEqual(tokens[0].meta.resolvedPath, '/root/notes/foo.md'); + const html = md.renderer.rules.mps_wikilink(tokens, 0, {}, RENDER_ENV); + assert.match(html, /href="\.\.\/notes\/foo\.md"/); + assert.doesNotMatch(html, /href="foo"/); + }); +}); + +test('oversized resolvable ![[name]] keeps a document-relative href', () => { + const big = 'x'.repeat(300000); + withTranscludeFixtures({ '/root/notes/foo.md': big }, () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('![[foo]]'); + assert.strictEqual(tokens[0].meta.resolvedPath, '/root/notes/foo.md'); + const html = md.renderer.rules.mps_wikilink(tokens, 0, {}, RENDER_ENV); + assert.match(html, /href="\.\.\/notes\/foo\.md"/); + }); +}); + +// #14: a note that wiki-links to itself emits just the fragment, so the click +// scrolls in place instead of reloading the document. +test('mps_wikilink self-link WITH fragment (resolvedPath === docPath) emits a bare fragment href', () => { + withIndex([{ absPath: '/root/docs/current.md' }], () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('[[current#Section A]]'); + const env = { currentDocument: { fsPath: '/root/docs/current.md' } }; + const html = md.renderer.rules.mps_wikilink(tokens, 0, {}, env); + assert.match(html, /href="#section-a"/); + assert.doesNotMatch(html, /current\.md/); + }); +}); + +// A bare self-link (no fragment) must stay a clickable link (its own +// basename), NOT collapse to an empty href - an empty href would be mistaken +// for a rejected dangerous scheme and render as an inert . +test('mps_wikilink bare self-link (no fragment) emits a clickable link, not an inert span', () => { + withIndex([{ absPath: '/root/docs/current.md' }], () => { + const md = makeMd(); + activate({ subscriptions: [] }).extendMarkdownIt(md); + const tokens = md.runInline('[[current]]'); + const env = { currentDocument: { fsPath: '/root/docs/current.md' } }; + const html = md.renderer.rules.mps_wikilink(tokens, 0, {}, env); + assert.match(html, //); + assert.doesNotMatch(html, / ({ get: (k, d) => (k === 'enabled' ? true : d) }), + findFiles: () => findFilesGate(), + createFileSystemWatcher: () => { + const w = { + disposed: false, + onDidCreate() {}, + onDidDelete() {}, + dispose() { this.disposed = true; }, + }; + created.push(w); + return w; + }, + }, + RelativePattern: function (base, glob) { this.base = base; this.glob = glob; }, + commands: { executeCommand: async () => {} }, + }; +} + +testAsync('a superseded rebuild bails without leaving a duplicate or orphan watcher', async () => { + __resetWikiStateForTest(); + // Rebuild #1: findFiles blocks until we release it. + let release1; + const gate1 = () => new Promise(res => { release1 = res; }); + const vscode1 = makeMockVscode(gate1); + const ctx = { subscriptions: [] }; + const p1 = __rebuildWorkspaceIndexForTest(ctx, vscode1); + + // Rebuild #2 starts while #1 is awaiting findFiles; its findFiles resolves + // immediately. This bumps the generation, superseding #1. + const vscode2 = makeMockVscode(() => Promise.resolve([{ fsPath: '/ws/b.md' }])); + const p2 = __rebuildWorkspaceIndexForTest(ctx, vscode2); + await p2; + + // Now release #1's findFiles. It detects (after the await) that it's + // superseded and bails - BEFORE creating its watcher, so it leaves nothing. + release1([{ fsPath: '/ws/a.md' }]); + await p1; + + // The superseded rebuild created no watcher (it bailed at the post-findFiles + // generation check). Only the winner's watcher exists, is live, and is the + // sole entry registered for disposal - no duplicate, no orphan. + assert.strictEqual(vscode1.created.length, 0, 'superseded rebuild created no watcher'); + const w2 = vscode2.created[0]; + assert.ok(w2 && !w2.disposed, "winning rebuild's watcher should be live"); + assert.ok(ctx.subscriptions.includes(w2), "winner's watcher registered in context.subscriptions"); + assert.strictEqual(ctx.subscriptions.length, 1, 'exactly one watcher registered (no leak)'); + __resetWikiStateForTest(); +}); + // ---- Summary ---------------------------------------------------------------- -console.log(`\n${passed} pass, ${failed} fail`); -process.exit(failed ? 1 : 0); +runAsyncTests().then(() => { + console.log(`\n${passed} pass, ${failed} fail`); + process.exit(failed ? 1 : 0); +}); diff --git a/test/visual/fixtures/notes/done/old-task.md b/test/visual/fixtures/notes/done/old-task.md new file mode 100644 index 0000000..a15f463 --- /dev/null +++ b/test/visual/fixtures/notes/done/old-task.md @@ -0,0 +1,12 @@ +--- +doc-type: example +title: Archived task +status: done +--- + +# Archived task + +This file lives deeper in the tree so the shortest-path tiebreak prefers +`short-note.md` over `done/old-task.md` when both are referenced by basename. + +A list item with a block marker. ^archived-block diff --git a/test/visual/fixtures/notes/related-document.md b/test/visual/fixtures/notes/related-document.md new file mode 100644 index 0000000..ec35281 --- /dev/null +++ b/test/visual/fixtures/notes/related-document.md @@ -0,0 +1,9 @@ +--- +doc-type: example +title: Related document +--- + +# Related document + +A neighbour note in the same folder. Resolved by basename when example.md +references `[[related-document]]`. diff --git a/test/visual/fixtures/notes/short-note.md b/test/visual/fixtures/notes/short-note.md new file mode 100644 index 0000000..0af5e25 --- /dev/null +++ b/test/visual/fixtures/notes/short-note.md @@ -0,0 +1,16 @@ +--- +doc-type: example +title: Short note +--- + +A short standalone note. Used as a transclusion target. + +## Section A + +This paragraph lives under Section A. It should appear when something embeds +`![[short-note#Section A]]`. + +## Section B + +This paragraph lives under Section B and should NOT appear when the embed +targets Section A. diff --git a/test/visual/render.js b/test/visual/render.js index 57aa0d8..f79b7d8 100644 --- a/test/visual/render.js +++ b/test/visual/render.js @@ -48,7 +48,29 @@ const pluginSourceMap = (md) => { } }; +// Populate the extension's workspace index from the fixtures directory so +// the harness can exercise wikilink resolution + transclusion paths. The +// real index is built from vscode.workspace.findFiles inside the running +// preview; here we seed it directly via the test seam. +function seedFixtureIndex() { + const fixturesRoot = path.join(__dirname, 'fixtures'); + if (!fs.existsSync(fixturesRoot)) return; + const index = new Map(); + const rootSortKey = fixturesRoot; + const walk = (dir) => { + for (const name of fs.readdirSync(dir)) { + const abs = path.join(dir, name); + const stat = fs.statSync(abs); + if (stat.isDirectory()) walk(abs); + else if (name.endsWith('.md')) ours.addToIndex(index, abs, rootSortKey); + } + }; + walk(fixturesRoot); + ours.__setWikiStateForTest({ index, config: { enabled: true, embedNotes: true, embedMaxBytes: 262144 } }); +} + function render(srcPath) { + seedFixtureIndex(); const md = new MarkdownIt({ html: true, linkify: true }); ours.activate().extendMarkdownIt(md); md.use(pluginSourceMap);