diff --git a/__tests__/unit/node/markdown/plugins/sourceAttrs.test.ts b/__tests__/unit/node/markdown/plugins/sourceAttrs.test.ts new file mode 100644 index 00000000..cdaa647e --- /dev/null +++ b/__tests__/unit/node/markdown/plugins/sourceAttrs.test.ts @@ -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, + { 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(`
{ + const { vueSrc } = await renderPage({ + 'part.md': '## From partial\n', + 'index.md': '# Page\n\n\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') + }) +}) diff --git a/__tests__/unit/node/markdownToVue.test.ts b/__tests__/unit/node/markdownToVue.test.ts index bcdafcf4..14394a01 100644 --- a/__tests__/unit/node/markdownToVue.test.ts +++ b/__tests__/unit/node/markdownToVue.test.ts @@ -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) diff --git a/__tests__/unit/node/plugins/localSearchPlugin.test.ts b/__tests__/unit/node/plugins/localSearchPlugin.test.ts index 0ecab64e..40ce753b 100644 --- a/__tests__/unit/node/plugins/localSearchPlugin.test.ts +++ b/__tests__/unit/node/plugins/localSearchPlugin.test.ts @@ -156,7 +156,8 @@ describe('node/plugins/localSearchPlugin', () => { siteConfig.site.base, false, false, - siteConfig + siteConfig, + false ) const rootFile = path.join(root, 'index.md') diff --git a/src/client/app/index.ts b/src/client/app/index.ts index 952e52f5..634e3542 100644 --- a/src/client/app/index.ts +++ b/src/client/app/index.ts @@ -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 } } diff --git a/src/client/app/openInEditor.ts b/src/client/app/openInEditor.ts new file mode 100644 index 00000000..f4ea06eb --- /dev/null +++ b/src/client/app/openInEditor.ts @@ -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(`[${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 + ) +} diff --git a/src/node/markdown/markdown.ts b/src/node/markdown/markdown.ts index 4a75343d..99d78691 100644 --- a/src/node/markdown/markdown.ts +++ b/src/node/markdown/markdown.ts @@ -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 + * ``, 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..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) { diff --git a/src/node/markdown/plugins/containers.ts b/src/node/markdown/plugins/containers.ts index 125e223c..1620e44f 100644 --- a/src/node/markdown/plugins/containers.ts +++ b/src/node/markdown/plugins/containers.ts @@ -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 `
${tabs}
\n` + const sourceLoc = tokens[idx].attrGet(SOURCE_LOC_ATTR) + const sourceLocAttr = sourceLoc + ? ` ${SOURCE_LOC_ATTR}="${md.utils.escapeHtml(sourceLoc)}"` + : '' + + return `
${tabs}
\n` } } @@ -235,6 +241,10 @@ export const gitHubAlertsPlugin = ( }) md.renderer.rules.github_alert_open = function (tokens, idx) { const { title, type } = tokens[idx].meta - return `

${title}

\n` + const sourceLoc = tokens[idx].attrGet(SOURCE_LOC_ATTR) + const sourceLocAttr = sourceLoc + ? ` ${SOURCE_LOC_ATTR}="${md.utils.escapeHtml(sourceLoc)}"` + : '' + return `

${title}

\n` } } diff --git a/src/node/markdown/plugins/preWrapper.ts b/src/node/markdown/plugins/preWrapper.ts index d5c66c7b..6b544923 100644 --- a/src/node/markdown/plugins/preWrapper.ts +++ b/src/node/markdown/plugins/preWrapper.ts @@ -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 ( - `
` + + `
` + `` + `${label}` + fence(...args) + diff --git a/src/node/markdown/plugins/sourceAttrs.ts b/src/node/markdown/plugins/sourceAttrs.ts new file mode 100644 index 00000000..c49e97c9 --- /dev/null +++ b/src/node/markdown/plugins/sourceAttrs.ts @@ -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` + ) + } + }) +} diff --git a/src/node/markdownToVue.ts b/src/node/markdownToVue.ts index 26a1afdd..287e90d5 100644 --- a/src/node/markdownToVue.ts +++ b/src/node/markdownToVue.ts @@ -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 { diff --git a/src/node/plugin.ts b/src/node/plugin.ts index 9592597b..585d38b2 100644 --- a/src/node/plugin.ts +++ b/src/node/plugin.ts @@ -136,7 +136,8 @@ export async function createVitePressPlugin( site.base, lastUpdated ?? false, cleanUrls ?? false, - siteConfig + siteConfig, + config.command === 'serve' ) }, diff --git a/src/shared/shared.ts b/src/shared/shared.ts index 1dbb3a59..0a302533 100644 --- a/src/shared/shared.ts +++ b/src/shared/shared.ts @@ -18,7 +18,10 @@ export type { LocaleConfig, LocaleSpecificConfig, MarkdownEnv, + MarkdownLineMap, + MarkdownLink, MarkdownLocaleOptions, + MarkdownSourceLoc, PageData, PageDataPayload, Route, diff --git a/types/shared.d.ts b/types/shared.d.ts index 75af49bc..e03c4d54 100644 --- a/types/shared.d.ts +++ b/types/shared.d.ts @@ -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 } /**