fix(markdown): keep positions exact in github alert bodies

The vp_github_alerts rule strips the alert marker from the first inline
token's content before inline parsing, shifting every position in the
body up by the marker line and breaking column re-alignment. The rule now
records how many lines it removed (token.meta.vpLineOffset) and the
source positions plugin adds them back.

Also re-emit the source-location attribute from the remaining hand-built
renderers that dropped it (::: v-pre and ::: raw wrappers, math blocks),
and pop it off fence tokens in preWrapper so a custom highlight falling
back to markdown-it's default fence renderer cannot emit it twice.

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

@ -628,6 +628,15 @@ describe('node/markdown/plugins/include', () => {
])
})
test('alert bodies inside includes resolve exactly', async () => {
await write('sub/part.md', '> [!NOTE]\n> body [x](./x)\n')
const { locs } = await renderLocs('<!-- @include: ./sub/part.md -->\n')
expect(locs).toEqual([
{ file: path.join(root, 'sub/part.md'), line: 2, column: 8 }
])
})
test('pages without includes get an identity line map', async () => {
const { env } = await render('# Hi\n\n[a](./a)\n')
expect(env.lineMap!.resolve(2)).toEqual({

@ -72,6 +72,32 @@ describe('node/markdown/plugins/sourceAttrs', () => {
)
})
test('hand-built wrappers re-emit the attribute', async () => {
const { vueSrc, file } = await renderPage(
{
'index.md': '::: v-pre\nvp\n:::\n\n::: raw\nrw\n:::\n\n$$\nx^2\n$$\n'
},
{ markdown: { math: true } }
)
const at = (line: number) => `data-v-inspector="${rel(file)}:${line}:1"`
expect(vueSrc).toContain(`<div v-pre ${at(1)}>`)
expect(vueSrc).toContain(`<div class="vp-raw" ${at(5)}>`)
expect(vueSrc).toContain(`tabindex="0" ${at(9)}`)
})
test('a custom highlight fallback does not double-stamp fences', async () => {
const { vueSrc, file } = await renderPage(
{ 'index.md': '```ts\ncode\n```\n' },
{ markdown: { highlight: (code: string) => code } }
)
const occurrences = vueSrc.match(/data-v-inspector="[^"]*:1:1"/g)
expect(occurrences).toHaveLength(1)
// and it sits on the wrapper, not the inner code element
expect(vueSrc).toContain(
`<div class="language-ts" data-v-inspector="${rel(file)}:1:1">`
)
})
test('builds render without source attributes', async () => {
const { vueSrc } = await renderPage(
{ 'index.md': '# Head\n\npara\n' },

@ -127,6 +127,25 @@ describe('markdown/plugins/sourcePositions', () => {
])
})
test('github alert bodies report exact positions', async () => {
const links = await collect(
'> [!TIP]\n> a [one](./one)\n> b [two](./two)\n'
)
expect(links.map((l) => l.loc)).toEqual([
{ file: '/docs/page.md', line: 2, column: 5 },
{ file: '/docs/page.md', line: 3, column: 5 }
])
})
test('github alert with a custom title keeps positions exact', async () => {
const links = await collect('> [!WARNING] Custom\n> body [x](./x)\n')
expect(links[0].loc).toEqual({
file: '/docs/page.md',
line: 2,
column: 8
})
})
test('header anchors get no synthetic position', async () => {
disposeMdItInstance()
const md = await createMarkdownRenderer('.', {

@ -64,7 +64,7 @@ import {
snippetPlugin,
type Options as SnippetPluginOptions
} from './plugins/snippet'
import { sourceAttrsPlugin } from './plugins/sourceAttrs'
import { renderSourceLocAttr, sourceAttrsPlugin } from './plugins/sourceAttrs'
import { sourcePositionsPlugin } from './plugins/sourcePositions'
import { tablePlugin } from './plugins/table'
@ -547,9 +547,15 @@ export async function createMarkdownRenderer(
}
const origMathBlock = md.renderer.rules.math_block!
md.renderer.rules.math_block = function (...args) {
// mathjax's renderer ignores token attrs - re-emit the source
// location alongside the v-pre/tabindex injection
const sourceLocAttr = renderSourceLocAttr(md!, args[0][args[1]])
return origMathBlock
.apply(this, args)
.replace(/^<mjx-container /, '<mjx-container v-pre tabindex="0" ')
.replace(
/^<mjx-container /,
`<mjx-container v-pre tabindex="0"${sourceLocAttr} `
)
}
} catch (error) {
throw new Error(

@ -8,8 +8,9 @@ import type {
MarkdownEnv,
MarkdownLocaleOptions
} from '../../shared'
import { countLineBreaks } from '../lineMap'
import { extractTitle } from './preWrapper'
import { SOURCE_LOC_ATTR } from './sourceAttrs'
import { renderSourceLocAttr } from './sourceAttrs'
export type { ContainerOptions } from '../../shared'
@ -42,12 +43,14 @@ export const containerPlugin = (
// explicitly escape Vue syntax
.use(container, {
name: 'v-pre',
openRender: () => `<div v-pre>\n`,
openRender: (tokens, idx) =>
`<div v-pre${renderSourceLocAttr(md, tokens[idx])}>\n`,
closeRender: () => `</div>\n`
})
.use(container, {
name: 'raw',
openRender: () => `<div class="vp-raw">\n`,
openRender: (tokens, idx) =>
`<div class="vp-raw"${renderSourceLocAttr(md, tokens[idx])}>\n`,
closeRender: () => `</div>\n`
})
.use(container, {
@ -185,12 +188,7 @@ function createCodeGroupOpenRender(md: MarkdownItAsync): RenderRule {
}
}
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`
return `<div class="vp-code-group"${renderSourceLocAttr(md, tokens[idx])}><div class="tabs">${tabs}</div><div class="blocks">\n`
}
}
@ -228,9 +226,20 @@ export const gitHubAlertsPlugin = (
const title =
match[2].trim() ||
titlesFor(titles, (state.env as MarkdownEnv)?.localeIndex)[type]
const contentBefore = firstContent.content
firstContent.content = firstContent.content
.slice(match[0].length)
.trimStart()
// the removed marker line(s) shift the inline content relative to
// firstContent.map - record the offset so source positions stay exact
const removedLines =
countLineBreaks(contentBefore) - countLineBreaks(firstContent.content)
if (removedLines) {
firstContent.meta = {
...firstContent.meta,
vpLineOffset: removedLines
}
}
open.type = 'github_alert_open'
open.tag = 'div'
open.meta = { title, type }
@ -241,10 +250,6 @@ export const gitHubAlertsPlugin = (
})
md.renderer.rules.github_alert_open = function (tokens, idx) {
const { title, type } = tokens[idx].meta
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`
return `<div class="${type} custom-block github-alert"${renderSourceLocAttr(md, tokens[idx])}><p class="custom-block-title">${title}</p>\n`
}
}

@ -1,7 +1,7 @@
import type { MarkdownItAsync } from 'markdown-it-async'
import type { MarkdownEnv, MarkdownLocaleOptions } from '../../shared'
import { SOURCE_LOC_ATTR } from './sourceAttrs'
import { popSourceLocAttr } from './sourceAttrs'
export interface Options {
codeCopyButton: { tooltipText: string; copiedText: string }
@ -43,11 +43,10 @@ export function preWrapperPlugin(md: MarkdownItAsync, options: Options) {
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)}"`
: ''
// attribute moves onto the wrapper - popped off the token because a
// custom `highlight` may fall back to markdown-it's default fence
// renderer, which does render token attrs
const sourceLocAttr = popSourceLocAttr(md, token)
return (
`<div class="language-${lang}${active}"${sourceLocAttr}>` +

@ -1,6 +1,8 @@
import path from 'node:path'
import type MarkdownIt from 'markdown-it'
import type { MarkdownItAsync } from 'markdown-it-async'
import type Token from 'markdown-it/lib/token.mjs'
import { slash, type MarkdownEnv } from '../../shared'
@ -24,6 +26,34 @@ export const SOURCE_LOC_ATTR = 'data-v-inspector'
* alerts) re-emit the attribute themselves; `html_block` is skipped since
* raw HTML and Vue components render their content verbatim.
*/
/**
* For renderers that build their markup by hand and would otherwise drop
* `token.attrs`: the source-location attribute rendered as ` name="value"`,
* or an empty string.
*/
export function renderSourceLocAttr(
md: Pick<MarkdownIt, 'utils'>,
token: Token
): string {
const loc = token.attrGet(SOURCE_LOC_ATTR)
return loc ? ` ${SOURCE_LOC_ATTR}="${md.utils.escapeHtml(loc)}"` : ''
}
/**
* Like `renderSourceLocAttr`, but also removes the attribute from the token
* for wrappers whose inner renderer may fall back to a default rule that
* renders token attrs, which would emit the location twice.
*/
export function popSourceLocAttr(
md: Pick<MarkdownIt, 'utils'>,
token: Token
): string {
const rendered = renderSourceLocAttr(md, token)
const index = token.attrIndex(SOURCE_LOC_ATTR)
if (index >= 0) token.attrs!.splice(index, 1)
return rendered
}
export function sourceAttrsPlugin(md: MarkdownItAsync): void {
md.core.ruler.push('vp_source_attrs', (state) => {
const env = state.env as MarkdownEnv

@ -206,7 +206,9 @@ function sourceLocs(state: StateCore): void {
const pos = child[POS]
if (!pos) continue
const line = token.map[0] + pos.dLine
// vpLineOffset: lines a pre-inline core rule removed from the token's
// content (the github-alerts marker) while its map kept spanning them
const line = token.map[0] + (token.meta?.vpLineOffset ?? 0) + pos.dLine
const resolved = env.lineMap?.resolve(line)
const loc: MarkdownSourceLoc = resolved
? { file: resolved.file, line: resolved.line + 1 }

Loading…
Cancel
Save