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