mirror of https://github.com/vuejs/vitepress
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
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')
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
@ -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`
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Reference in new issue