feat: jump from rendered markdown to its source in dev

Page renders in dev stamp block elements with data-v-inspector
attributes carrying the cwd-relative source file, line and column —
include-aware through the line map, so content pulled in via
`<!-- @include -->` points at the included file. The attribute is the
one vite-plugin-vue-inspector's overlay reads off arbitrary elements,
so the Vue DevTools component inspector jumps to the markdown source
out of the box; a ~50-line dev-only client handler additionally makes
alt+click open the editor through Vite's built-in /__open-in-editor
endpoint with no plugins installed (#4293). Fence wrappers, code
groups and GitHub alerts re-emit the attribute from their hand-built
markup; builds, the local search index and content loader output are
env-gated and stay byte-identical. Opt out with
`markdown.sourceAttrs: false`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/md-sourcemaps
Divyansh Singh 2 weeks ago
parent 2b62f25b36
commit 6886a95953

@ -0,0 +1,90 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { resolveConfig } from 'node/config'
import {
disposeMdItInstance,
type MarkdownOptions
} from 'node/markdown/markdown'
import { createMarkdownToVueRenderFn } from 'node/markdownToVue'
import { slash } from 'node/shared'
describe('node/markdown/plugins/sourceAttrs', () => {
let root: string
beforeEach(async () => {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-source-attrs-'))
})
afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
function rel(file: string) {
return slash(path.relative(process.cwd(), file))
}
async function renderPage(
files: Record<string, string>,
{ dev = true, markdown = {} as MarkdownOptions } = {}
) {
disposeMdItInstance()
for (const [name, text] of Object.entries(files)) {
await writeFile(path.join(root, name), text)
}
const file = path.join(root, 'index.md')
const siteConfig = await resolveConfig(root, 'build', 'production')
const render = await createMarkdownToVueRenderFn(
siteConfig.srcDir,
{ cache: false, ...markdown },
'/',
false,
false,
siteConfig,
dev
)
return { vueSrc: (await render(files['index.md'], file)).vueSrc, file }
}
test('stamps block elements with their source location in dev', async () => {
const { vueSrc, file } = await renderPage({
'index.md':
'---\nt: 1\n---\n\n# Head\n\npara\n\n::: tip\nboxed\n:::\n\n```ts\ncode\n```\n\n> [!NOTE]\n> alert\n'
})
const at = (line: number) => `data-v-inspector="${rel(file)}:${line}:1"`
expect(vueSrc).toContain(at(5)) // heading
expect(vueSrc).toContain(at(7)) // paragraph
expect(vueSrc).toContain(at(9)) // container
expect(vueSrc).toContain(`<div class="language-ts" ${at(13)}`) // fence wrapper
expect(vueSrc).toContain(
`<div class="note custom-block github-alert" ${at(17)}` // gh alert
)
})
test('locations resolve into included files', async () => {
const { vueSrc } = await renderPage({
'part.md': '## From partial\n',
'index.md': '# Page\n\n<!-- @include: ./part.md -->\n'
})
expect(vueSrc).toContain(
`data-v-inspector="${rel(path.join(root, 'part.md'))}:1:1"`
)
})
test('builds render without source attributes', async () => {
const { vueSrc } = await renderPage(
{ 'index.md': '# Head\n\npara\n' },
{ dev: false }
)
expect(vueSrc).not.toContain('data-v-inspector')
})
test('markdown.sourceAttrs: false keeps the dev DOM clean', async () => {
const { vueSrc } = await renderPage(
{ 'index.md': '# Head\n\npara\n' },
{ markdown: { sourceAttrs: false } }
)
expect(vueSrc).not.toContain('data-v-inspector')
})
})

@ -30,7 +30,8 @@ describe('node/markdownToVue', () => {
'/',
false,
false,
siteConfig
siteConfig,
false
)
const result = await render(src, file)
@ -59,7 +60,8 @@ describe('node/markdownToVue', () => {
'/',
false,
false,
siteConfig
siteConfig,
false
)
const result = await render(src, file)
@ -93,7 +95,8 @@ describe('node/markdownToVue', () => {
'/',
false,
false,
siteConfig
siteConfig,
false
)
const result = await render(src, file)
@ -119,7 +122,8 @@ describe('node/markdownToVue', () => {
'/',
false,
false,
siteConfig
siteConfig,
false
)
const result = await render(src, file)
@ -155,7 +159,8 @@ describe('node/markdownToVue', () => {
'/',
false,
false,
siteConfig
siteConfig,
false
)
const result = await render(src, file)
@ -210,7 +215,8 @@ describe('node/markdownToVue', () => {
'/',
false,
false,
siteConfig
siteConfig,
false
)
const result = await render(src, file)
@ -245,7 +251,8 @@ describe('node/markdownToVue', () => {
'/',
false,
false,
siteConfig
siteConfig,
false
)
const result = await render('# Home\n', 'C:/site/docs/en/index.md')
@ -277,7 +284,8 @@ describe('node/markdownToVue', () => {
'/',
false,
false,
siteConfig
siteConfig,
false
)
const result = await render(src, file)

@ -156,7 +156,8 @@ describe('node/plugins/localSearchPlugin', () => {
siteConfig.site.base,
false,
false,
siteConfig
siteConfig,
false
)
const rootFile = path.join(root, 'index.md')

@ -113,6 +113,13 @@ export async function createApp() {
)
}
// alt+click jump-to-source for the source locations stamped in dev
if (import.meta.env.DEV && inBrowser) {
import('./openInEditor.js').then(({ setupOpenInEditor }) =>
setupOpenInEditor()
)
}
return { app, router, data }
}

@ -0,0 +1,56 @@
// dev-only: alt+click on rendered markdown content jumps the editor to the
// source location carried by the `data-v-inspector` attributes (see the
// sourceAttrs markdown plugin), through the dev server's built-in
// `/__open-in-editor` endpoint. Needs no plugins — with the Vue DevTools
// component inspector active, its own overlay takes over instead.
const ATTR = 'data-v-inspector'
export function setupOpenInEditor(): void {
let target: HTMLElement | undefined
let previousOutline = ''
const clear = () => {
if (target) {
target.style.outline = previousOutline
target = undefined
}
}
const find = (el: EventTarget | null) =>
el instanceof Element ? el.closest<HTMLElement>(`[${ATTR}]`) : null
window.addEventListener('mousemove', (e) => {
if (!e.altKey) return clear()
const el = find(e.target)
if (el === target) return
clear()
if (el) {
target = el
previousOutline = el.style.outline
el.style.outline = '1px solid var(--vp-c-brand-1, #3451b2)'
}
})
window.addEventListener('keyup', (e) => {
if (e.key === 'Alt') clear()
})
window.addEventListener('blur', clear)
window.addEventListener(
'click',
(e) => {
if (!e.altKey) return
// the vue devtools inspector overlay handles clicks itself while active
if ((window as any).__VUE_INSPECTOR__?.enabled) return
const loc = find(e.target)?.getAttribute(ATTR)
if (!loc) return
e.preventDefault()
e.stopPropagation()
clear()
fetch(
`${import.meta.env.BASE_URL}__open-in-editor?file=${encodeURIComponent(loc)}`
)
},
true
)
}

@ -64,6 +64,7 @@ import {
snippetPlugin,
type Options as SnippetPluginOptions
} from './plugins/snippet'
import { sourceAttrsPlugin } from './plugins/sourceAttrs'
import { sourcePositionsPlugin } from './plugins/sourcePositions'
import { tablePlugin } from './plugins/table'
@ -349,6 +350,17 @@ export interface MarkdownOptions extends MarkdownItAsyncOptions {
* @see https://github.com/mdit-vue/mdit-vue/tree/main/packages/plugin-sfc
*/
sfc?: SfcPluginOptions
/**
* Stamp rendered block elements with the source file, line and column they
* were authored at (`data-v-inspector` attributes) while running the dev
* server, so alt+click and the Vue DevTools component inspector jump the
* editor to the markdown source for content pulled in via
* `<!--@include-->`, the included file. Never affects builds, the local
* search index or content loader output. Set to `false` to keep the dev
* DOM attribute-free.
* @default true
*/
sourceAttrs?: boolean
}
// folds `locales.<index>.markdown` entries from the site config into
@ -585,6 +597,9 @@ export async function createMarkdownRenderer(
// inline rules are wrapped lazily on first parse, so rules registered by
// the `config` hook below are position-tracked too
sourcePositionsPlugin(md)
if (options.sourceAttrs !== false) {
sourceAttrsPlugin(md)
}
// apply user config
if (options.config) {

@ -9,6 +9,7 @@ import type {
MarkdownLocaleOptions
} from '../../shared'
import { extractTitle } from './preWrapper'
import { SOURCE_LOC_ATTR } from './sourceAttrs'
export type { ContainerOptions } from '../../shared'
@ -184,7 +185,12 @@ function createCodeGroupOpenRender(md: MarkdownItAsync): RenderRule {
}
}
return `<div class="vp-code-group"><div class="tabs">${tabs}</div><div class="blocks">\n`
const sourceLoc = tokens[idx].attrGet(SOURCE_LOC_ATTR)
const sourceLocAttr = sourceLoc
? ` ${SOURCE_LOC_ATTR}="${md.utils.escapeHtml(sourceLoc)}"`
: ''
return `<div class="vp-code-group"${sourceLocAttr}><div class="tabs">${tabs}</div><div class="blocks">\n`
}
}
@ -235,6 +241,10 @@ export const gitHubAlertsPlugin = (
})
md.renderer.rules.github_alert_open = function (tokens, idx) {
const { title, type } = tokens[idx].meta
return `<div class="${type} custom-block github-alert"><p class="custom-block-title">${title}</p>\n`
const sourceLoc = tokens[idx].attrGet(SOURCE_LOC_ATTR)
const sourceLocAttr = sourceLoc
? ` ${SOURCE_LOC_ATTR}="${md.utils.escapeHtml(sourceLoc)}"`
: ''
return `<div class="${type} custom-block github-alert"${sourceLocAttr}><p class="custom-block-title">${title}</p>\n`
}
}

@ -1,6 +1,7 @@
import type { MarkdownItAsync } from 'markdown-it-async'
import type { MarkdownEnv, MarkdownLocaleOptions } from '../../shared'
import { SOURCE_LOC_ATTR } from './sourceAttrs'
export interface Options {
codeCopyButton: { tooltipText: string; copiedText: string }
@ -41,8 +42,15 @@ export function preWrapperPlugin(md: MarkdownItAsync, options: Options) {
const copiedText =
localeButton?.copiedText || options.codeCopyButton.copiedText
// the fence renderer builds its markup by hand, so the source-location
// attribute is re-emitted on the wrapper
const sourceLoc = token.attrGet(SOURCE_LOC_ATTR)
const sourceLocAttr = sourceLoc
? ` ${SOURCE_LOC_ATTR}="${md.utils.escapeHtml(sourceLoc)}"`
: ''
return (
`<div class="language-${lang}${active}">` +
`<div class="language-${lang}${active}"${sourceLocAttr}>` +
`<button title="${tooltipText}" data-copied="${copiedText}" class="copy"></button>` +
`<span class="lang">${label}</span>` +
fence(...args) +

@ -0,0 +1,53 @@
import path from 'node:path'
import type { MarkdownItAsync } from 'markdown-it-async'
import { slash, type MarkdownEnv } from '../../shared'
/**
* The attribute carrying an element's source location in dev,
* `"cwd-relative-path:line:column"` (1-based, both parts required by every
* consumer's parser). It is the attribute `vite-plugin-vue-inspector`'s
* overlay reads off arbitrary DOM elements markdown content compiles into
* static vnodes without per-element instrumentation, so the attribute is the
* only channel and what VitePress's own dev open-in-editor handler uses.
*/
export const SOURCE_LOC_ATTR = 'data-v-inspector'
/**
* Stamps rendered block elements with the source location they were authored
* at (include-aware via `env.lineMap`). Only runs for envs that opt in
* (`env.emitSourceLoc`, set for page renders in dev) local search
* indexing, content loaders and builds stay byte-identical.
*
* Renderers that build their markup by hand (fences, code groups, GitHub
* alerts) re-emit the attribute themselves; `html_block` is skipped since
* raw HTML and Vue components render their content verbatim.
*/
export function sourceAttrsPlugin(md: MarkdownItAsync): void {
md.core.ruler.push('vp_source_attrs', (state) => {
const env = state.env as MarkdownEnv
if (!env.emitSourceLoc) return
for (const token of state.tokens) {
if (
!token.map ||
token.nesting < 0 ||
token.hidden ||
!token.tag ||
token.type === 'inline' ||
token.type === 'html_block'
) {
continue
}
const { file, line } = env.lineMap
? env.lineMap.resolve(token.map[0])
: { file: env.realPath ?? env.path, line: token.map[0] }
if (!file) continue
token.attrSet(
SOURCE_LOC_ATTR,
`${slash(path.relative(process.cwd(), file))}:${line + 1}:1`
)
}
})
}

@ -119,7 +119,8 @@ export async function createMarkdownToVueRenderFn(
base: string,
includeLastUpdatedData: boolean,
cleanUrls: boolean,
siteConfig: SiteConfig
siteConfig: SiteConfig,
dev: boolean
) {
const md = await createMarkdownRenderer(
srcDir,
@ -144,7 +145,7 @@ export async function createMarkdownToVueRenderFn(
const relativePath = slash(path.relative(srcDir, file))
const srcHash = hash('sha256', src, 'base64url')
const cacheKey = `${srcHash}:${ts}:${relativePath}`
const cacheKey = `${srcHash}:${ts}:${dev}:${relativePath}`
if (options.cache !== false) {
const cached = cache.get(cacheKey)
if (cached) {
@ -176,7 +177,10 @@ export async function createMarkdownToVueRenderFn(
relativizeUrls: true,
includes: [],
realPath: fileOrig,
localeIndex
localeIndex,
// page renders in dev carry source-location attributes for
// jump-to-source; everything else stays clean
emitSourceLoc: dev
}
let html: string
try {

@ -136,7 +136,8 @@ export async function createVitePressPlugin(
site.base,
lastUpdated ?? false,
cleanUrls ?? false,
siteConfig
siteConfig,
config.command === 'serve'
)
},

@ -18,7 +18,10 @@ export type {
LocaleConfig,
LocaleSpecificConfig,
MarkdownEnv,
MarkdownLineMap,
MarkdownLink,
MarkdownLocaleOptions,
MarkdownSourceLoc,
PageData,
PageDataPayload,
Route,

7
types/shared.d.ts vendored

@ -626,6 +626,13 @@ export interface MarkdownEnv {
* @internal
*/
eagerInterpolations?: { expression: string; value: string }[]
/**
* Whether to stamp rendered block elements with their source location
* (`data-v-inspector` attributes). Set for page renders in dev; envs
* without it (local search, content loaders, builds) render clean HTML.
* @internal
*/
emitSourceLoc?: boolean
}
/**

Loading…
Cancel
Save