From ecd5a49a2a0fab3b01be2cb342057e1ab63d94dd Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:34:04 +0530 Subject: [PATCH] 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 --- .../node/markdown/plugins/include.test.ts | 9 +++++ .../node/markdown/plugins/sourceAttrs.test.ts | 26 +++++++++++++++ .../markdown/plugins/sourcePositions.test.ts | 19 +++++++++++ src/node/markdown/markdown.ts | 10 ++++-- src/node/markdown/plugins/containers.ts | 33 +++++++++++-------- src/node/markdown/plugins/preWrapper.ts | 11 +++---- src/node/markdown/plugins/sourceAttrs.ts | 30 +++++++++++++++++ src/node/markdown/plugins/sourcePositions.ts | 4 ++- 8 files changed, 119 insertions(+), 23 deletions(-) diff --git a/__tests__/unit/node/markdown/plugins/include.test.ts b/__tests__/unit/node/markdown/plugins/include.test.ts index 772106b0..1ca005e3 100644 --- a/__tests__/unit/node/markdown/plugins/include.test.ts +++ b/__tests__/unit/node/markdown/plugins/include.test.ts @@ -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('\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({ diff --git a/__tests__/unit/node/markdown/plugins/sourceAttrs.test.ts b/__tests__/unit/node/markdown/plugins/sourceAttrs.test.ts index cdaa647e..441f4207 100644 --- a/__tests__/unit/node/markdown/plugins/sourceAttrs.test.ts +++ b/__tests__/unit/node/markdown/plugins/sourceAttrs.test.ts @@ -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(`
`) + expect(vueSrc).toContain(`
`) + 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( + `
` + ) + }) + test('builds render without source attributes', async () => { const { vueSrc } = await renderPage( { 'index.md': '# Head\n\npara\n' }, diff --git a/__tests__/unit/node/markdown/plugins/sourcePositions.test.ts b/__tests__/unit/node/markdown/plugins/sourcePositions.test.ts index 4a070cef..a7428b7f 100644 --- a/__tests__/unit/node/markdown/plugins/sourcePositions.test.ts +++ b/__tests__/unit/node/markdown/plugins/sourcePositions.test.ts @@ -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('.', { diff --git a/src/node/markdown/markdown.ts b/src/node/markdown/markdown.ts index 99d78691..abd63581 100644 --- a/src/node/markdown/markdown.ts +++ b/src/node/markdown/markdown.ts @@ -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(/^ `
\n`, + openRender: (tokens, idx) => + `
\n`, closeRender: () => `
\n` }) .use(container, { name: 'raw', - openRender: () => `
\n`, + openRender: (tokens, idx) => + `
\n`, closeRender: () => `
\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 `
${tabs}
\n` + return `
${tabs}
\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 `

${title}

\n` + return `

${title}

\n` } } diff --git a/src/node/markdown/plugins/preWrapper.ts b/src/node/markdown/plugins/preWrapper.ts index 6b544923..dcdcd1e5 100644 --- a/src/node/markdown/plugins/preWrapper.ts +++ b/src/node/markdown/plugins/preWrapper.ts @@ -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 ( `
` + diff --git a/src/node/markdown/plugins/sourceAttrs.ts b/src/node/markdown/plugins/sourceAttrs.ts index c49e97c9..2829a103 100644 --- a/src/node/markdown/plugins/sourceAttrs.ts +++ b/src/node/markdown/plugins/sourceAttrs.ts @@ -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, + 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, + 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 diff --git a/src/node/markdown/plugins/sourcePositions.ts b/src/node/markdown/plugins/sourcePositions.ts index 01966b23..ce377bf7 100644 --- a/src/node/markdown/plugins/sourcePositions.ts +++ b/src/node/markdown/plugins/sourcePositions.ts @@ -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 }