From 0a41ecbe1781699e06837598a238dc9fbad48cf4 Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:37:04 +0530 Subject: [PATCH 01/10] refactor(markdown): keep token maps in full-source coordinates Vendor @mdit-vue/plugin-frontmatter and pad the stripped frontmatter with blank lines before parsing, so every token.map stays in the coordinates of the file instead of shifting up by the frontmatter height. This deletes the contentLineOffset / src.endsWith(content) reconstruction in markdownToVue. Offering the padding upstream as a preserveLines option is a follow-up. Co-Authored-By: Claude Fable 5 --- src/node/markdown/markdown.ts | 6 ++-- src/node/markdown/plugins/frontmatter.ts | 39 ++++++++++++++++++++++++ src/node/markdownToVue.ts | 14 +-------- 3 files changed, 42 insertions(+), 17 deletions(-) create mode 100644 src/node/markdown/plugins/frontmatter.ts diff --git a/src/node/markdown/markdown.ts b/src/node/markdown/markdown.ts index eeb90ced..f13e7949 100644 --- a/src/node/markdown/markdown.ts +++ b/src/node/markdown/markdown.ts @@ -4,10 +4,7 @@ import { componentPlugin, type ComponentPluginOptions } from '@mdit-vue/plugin-component' -import { - frontmatterPlugin, - type FrontmatterPluginOptions -} from '@mdit-vue/plugin-frontmatter' +import type { FrontmatterPluginOptions } from '@mdit-vue/plugin-frontmatter' import { headersPlugin, type HeadersPluginOptions @@ -52,6 +49,7 @@ import { type ContainerOptions } from './plugins/containers' import { eagerFrontmatterInterpolationPlugin } from './plugins/eagerFrontmatterInterpolation' +import { frontmatterPlugin } from './plugins/frontmatter' import { highlight as createHighlighter } from './plugins/highlight' import { imagePlugin, type Options as ImageOptions } from './plugins/image' import { diff --git a/src/node/markdown/plugins/frontmatter.ts b/src/node/markdown/plugins/frontmatter.ts new file mode 100644 index 00000000..6f122882 --- /dev/null +++ b/src/node/markdown/plugins/frontmatter.ts @@ -0,0 +1,39 @@ +import type { FrontmatterPluginOptions } from '@mdit-vue/plugin-frontmatter' +import matter from 'gray-matter' +import type { MarkdownItAsync } from 'markdown-it-async' + +/** + * Vendored from `@mdit-vue/plugin-frontmatter` (extracts `env.frontmatter`, + * `env.content` and `env.excerpt` before parsing), with one behavioral + * change: the stripped frontmatter block is replaced with blank lines, so + * every `token.map` stays in the coordinates of the full source instead of + * shifting up by the frontmatter height. Blank lines produce no tokens, so + * the rendered output is unchanged. Offering this upstream as a + * `preserveLines` option is tracked as a follow-up. + */ +export function frontmatterPlugin( + md: MarkdownItAsync, + { grayMatterOptions, renderExcerpt = true }: FrontmatterPluginOptions = {} +): void { + const parse = md.parse.bind(md) + md.parse = (src, env: Record = {}) => { + const { data, content, excerpt = '' } = matter(src, grayMatterOptions) + + env.content = content + env.frontmatter = { ...(env.frontmatter as object), ...data } + env.excerpt = + renderExcerpt && excerpt ? md.render(excerpt, { ...env }) : excerpt + + // gray-matter only ever removes lines from the top of the file, so the + // difference in line-break counts is exactly the removed line count + const removed = + src.length === content.length + ? 0 + : countLineBreaks(src) - countLineBreaks(content) + return parse(removed > 0 ? '\n'.repeat(removed) + content : content, env) + } +} + +function countLineBreaks(str: string): number { + return str.match(/\r\n|[\r\n]/g)?.length ?? 0 +} diff --git a/src/node/markdownToVue.ts b/src/node/markdownToVue.ts index 661af795..ceb67235 100644 --- a/src/node/markdownToVue.ts +++ b/src/node/markdownToVue.ts @@ -174,7 +174,6 @@ export async function createMarkdownToVueRenderFn( throw e } const { - content, frontmatter = {}, headers = [], includes = [], @@ -183,10 +182,6 @@ export async function createMarkdownToVueRenderFn( sfcBlocks, title = '' } = env - src = env.src ?? src - const contentLineOffset = countLineBreaks( - content && src.endsWith(content) ? src.slice(0, -content.length) : '' - ) // validate data.links const deadLinks: MarkdownCompileResult['deadLinks'] = [] @@ -219,10 +214,7 @@ export async function createMarkdownToVueRenderFn( const dir = path.dirname(file) for (const [index, rawUrl] of links.entries()) { let url = rawUrl - const line = - linkLines[index] == null - ? undefined - : linkLines[index] + contentLineOffset + const line = linkLines[index] == null ? undefined : linkLines[index] const { pathname } = new URL(url, 'http://a.com') if (!treatAsHtml(pathname)) continue @@ -390,10 +382,6 @@ const inferDescription = (frontmatter: Record) => { return (head && getHeadMetaContent(head, 'description')) || '' } -function countLineBreaks(str: string) { - return str.match(/\r?\n/g)?.length ?? 0 -} - const getHeadMetaContent = (head: HeadConfig[], name: string) => { if (!head || !head.length) { return undefined From abb6432ba9d985e39506d49c9881ba52f08680fb Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:40:53 +0530 Subject: [PATCH 02/10] feat(markdown): track exact source positions for links and images Every inline rule is wrapped (lazily on first parse, so user config rules are covered) to record the source range it consumed onto the tokens it emits; a terminal core rule converts ranges into {file, line, column} via the parent inline map and env.lineMap, stamped as token.meta.vpLoc together with the decoded as-authored destination (token.meta.vpRaw). Table-cell inline tokens inherit their row's map, and links spliced in by the linkify core rule recover at least their line. Ranges ride on token objects, so plugins that splice children or mutate content don't disturb them. Co-Authored-By: Claude Fable 5 --- .../markdown/plugins/sourcePositions.test.ts | 146 +++++++++ src/node/markdown/markdown.ts | 5 + src/node/markdown/plugins/sourcePositions.ts | 295 ++++++++++++++++++ types/shared.d.ts | 35 +++ 4 files changed, 481 insertions(+) create mode 100644 __tests__/unit/node/markdown/plugins/sourcePositions.test.ts create mode 100644 src/node/markdown/plugins/sourcePositions.ts diff --git a/__tests__/unit/node/markdown/plugins/sourcePositions.test.ts b/__tests__/unit/node/markdown/plugins/sourcePositions.test.ts new file mode 100644 index 00000000..4a070cef --- /dev/null +++ b/__tests__/unit/node/markdown/plugins/sourcePositions.test.ts @@ -0,0 +1,146 @@ +import { + createMarkdownRenderer, + disposeMdItInstance +} from 'node/markdown/markdown' +import type { MarkdownEnv, MarkdownSourceLoc } from 'node/shared' + +async function collect(src: string, env: Partial = {}) { + disposeMdItInstance() + const md = await createMarkdownRenderer('.', { + highlight: (code) => code + }) + const fullEnv = { + path: '/docs/page.md', + relativePath: 'page.md', + cleanUrls: false, + ...env + } as MarkdownEnv + const tokens = md.parse(src, fullEnv) + const found: { type: string; raw?: string; loc?: MarkdownSourceLoc }[] = [] + for (const token of tokens) { + for (const child of token.children ?? []) { + if ( + (child.type === 'link_open' || child.type === 'image') && + child.attrGet('class') !== 'header-anchor' + ) { + found.push({ + type: child.type, + raw: child.meta?.vpRaw, + loc: child.meta?.vpLoc + }) + } + } + } + return found +} + +describe('markdown/plugins/sourcePositions', () => { + test('exact lines and columns across a multi-line paragraph', async () => { + const links = await collect('para one [a](./a)\nand two [b](./b) end\n') + expect(links.map((l) => l.loc)).toEqual([ + { file: '/docs/page.md', line: 1, column: 10 }, + { file: '/docs/page.md', line: 2, column: 9 } + ]) + }) + + test('lines stay file-accurate after frontmatter', async () => { + const links = await collect( + '---\ntitle: x\n---\n\n# H\n\nsee [a](./a)\nand [b](./b)\n' + ) + expect(links.map((l) => l.loc)).toEqual([ + { file: '/docs/page.md', line: 7, column: 5 }, + { file: '/docs/page.md', line: 8, column: 5 } + ]) + }) + + test('table-cell links inherit their exact row line', async () => { + const links = await collect( + '| a | b |\n|---|---|\n| [x](./x) | y |\n| r2 | [z](./z) |\n' + ) + expect(links.map((l) => l.loc?.line)).toEqual([3, 4]) + }) + + test('blockquote columns account for the stripped prefix', async () => { + const links = await collect('> quoted [q](./q)\n> > deep [d](./d)\n') + expect(links.map((l) => l.loc)).toEqual([ + { file: '/docs/page.md', line: 1, column: 10 }, + { file: '/docs/page.md', line: 2, column: 10 } + ]) + }) + + test('multi-line code span before a link does not shift it', async () => { + // code-span newlines become spaces in token content; offset capture is + // unaffected, the softbreak-walking approach would be off by one here + const links = await collect('a `code\nspan` then [x](./x)\nnext [y](./y)\n') + expect(links.map((l) => l.loc?.line)).toEqual([2, 3]) + }) + + test('list continuation lines and nested items', async () => { + const links = await collect('- item\n cont [i](./i)\n- [j](./j)\n') + expect(links.map((l) => l.loc)).toEqual([ + { file: '/docs/page.md', line: 2, column: 8 }, + { file: '/docs/page.md', line: 3, column: 3 } + ]) + }) + + test('reference links report the usage site', async () => { + const links = await collect('start\n\nuse [ref][r] here\n\n[r]: ./target\n') + expect(links.map((l) => l.loc?.line)).toEqual([3]) + }) + + test('images and autolinks carry positions and raw destinations', async () => { + const found = await collect( + 'pic ![alt](./img.png) and \n' + ) + expect(found).toEqual([ + { + type: 'image', + raw: './img.png', + loc: { file: '/docs/page.md', line: 1, column: 5 } + }, + { + type: 'link_open', + raw: 'http://localhost:5173/x', + loc: { file: '/docs/page.md', line: 1, column: 27 } + } + ]) + }) + + test('linkified bare URLs after breaks get at least the line', async () => { + const links = await collect('one\ntwo http://localhost:5173/a end\n') + expect(links[0].loc?.line).toBe(2) + }) + + test('raw destination is decoded and keeps hash/query', async () => { + const links = await collect('[c](./中文.md#über?q=1)\n') + expect(links[0].raw).toBe('./中文.md#über?q=1') + expect(links[0].loc).toEqual({ file: '/docs/page.md', line: 1, column: 1 }) + }) + + test('emphasis, attrs suffixes and emoji before links do not break offsets', async () => { + const links = await collect( + '**bold** :smile: [a](./a){.cls}\nplain [b](./b)\n' + ) + expect(links.map((l) => l.loc)).toEqual([ + { file: '/docs/page.md', line: 1, column: 18 }, + { file: '/docs/page.md', line: 2, column: 7 } + ]) + }) + + test('header anchors get no synthetic position', async () => { + disposeMdItInstance() + const md = await createMarkdownRenderer('.', { + highlight: (code) => code + }) + const tokens = md.parse('# Heading\n', { + path: '/docs/page.md', + relativePath: 'page.md', + cleanUrls: false + } as MarkdownEnv) + const permalink = tokens + .flatMap((t) => t.children ?? []) + .find((t) => t.attrGet('class') === 'header-anchor') + expect(permalink).toBeDefined() + expect(permalink!.meta?.vpLoc).toBeUndefined() + }) +}) diff --git a/src/node/markdown/markdown.ts b/src/node/markdown/markdown.ts index f13e7949..4a75343d 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 { sourcePositionsPlugin } from './plugins/sourcePositions' import { tablePlugin } from './plugins/table' export type { Header } from '../shared' @@ -581,6 +582,10 @@ export async function createMarkdownRenderer( eagerFrontmatterInterpolationPlugin(md) } + // inline rules are wrapped lazily on first parse, so rules registered by + // the `config` hook below are position-tracked too + sourcePositionsPlugin(md) + // apply user config if (options.config) { await options.config(md) diff --git a/src/node/markdown/plugins/sourcePositions.ts b/src/node/markdown/plugins/sourcePositions.ts new file mode 100644 index 00000000..d3608669 --- /dev/null +++ b/src/node/markdown/plugins/sourcePositions.ts @@ -0,0 +1,295 @@ +import type { MarkdownItAsync } from 'markdown-it-async' +import type MarkdownIt from 'markdown-it' +import type StateCore from 'markdown-it/lib/rules_core/state_core.mjs' +import type Token from 'markdown-it/lib/token.mjs' + +import type { MarkdownEnv, MarkdownSourceLoc } from '../../shared' + +// consumed source range of an inline token, [start, end) offsets into the +// inline parser's src. Symbols ride on the token objects themselves, so they +// survive plugins that splice children arrays or mutate `inline.content` +// (attrs, emoji, tasklist, github alerts) — index- or content-based +// reconstruction would not. +const RANGE = Symbol('vpRange') +// line-relative position, precomputed while the pristine inline src is still +// available (later core rules mutate token content) +const POS = Symbol('vpPos') +const INSTALLED = Symbol('vpPositionsInstalled') + +interface InlinePos { + /** line breaks in the inline src before the range start */ + dLine: number + /** offset within that inline-src line; -1 when only the line is known */ + startInLine: number + /** that inline-src line's text, for column re-alignment against the raw source */ + lineText: string +} + +type PositionedToken = Token & { + [RANGE]?: [number, number] + [POS]?: InlinePos +} + +/** + * Tracks exact source positions for links and images. markdown-it only maps + * block-level tokens to lines, so on its own every link in a multi-line + * paragraph reports the paragraph's first line, and table-cell links (whose + * inline tokens carry no map at all) report nothing. + * + * Mechanism: every inline rule is wrapped (lazily, on first parse, so rules + * registered by user `config` hooks are covered too) to record the source + * range it consumed onto the tokens it emitted. A final core rule converts + * ranges into `{file, line, column}` via the parent inline token's map and + * `env.lineMap`, and stamps the result as `token.meta.vpLoc` on `link_open` + * and `image` tokens, along with the decoded pre-normalization destination + * as `token.meta.vpRaw`. + */ +export function sourcePositionsPlugin(md: MarkdownItAsync): void { + md.core.ruler.before('normalize', 'vp_inline_positions', (state) => { + const inline = state.md.inline as unknown as Record + if (!inline[INSTALLED]) { + inline[INSTALLED] = true + installInlineWrappers(state.md) + installParseWrapper(state.md) + } + }) + + // markdown-it's table rule leaves cell inline tokens without a map; the + // enclosing row token has one, so cells inherit their exact row line + md.core.ruler.after('block', 'vp_table_cell_maps', (state) => { + let rowMap: [number, number] | null = null + for (const token of state.tokens) { + if (token.type === 'tr_open') rowMap = token.map + else if (token.type === 'tr_close') rowMap = null + else if (rowMap && token.type === 'inline' && !token.map) + token.map = [rowMap[0], rowMap[1]] + } + }) + + // the linkify *core* rule splices brand-new link tokens into children after + // inline parsing; they carry no range, so recover at least the line from + // the break tokens before them + md.core.ruler.after('linkify', 'vp_linkify_positions', (state) => { + if (!state.md.options.linkify) return + for (const token of state.tokens) { + if (token.type !== 'inline' || !token.children) continue + let breaks = 0 + for (const child of token.children as PositionedToken[]) { + if ( + child.type === 'link_open' && + child.markup === 'linkify' && + !child[RANGE] + ) { + child[POS] = { dLine: breaks, startInLine: -1, lineText: '' } + } else if (child.type === 'softbreak' || child.type === 'hardbreak') { + breaks++ + } else if (child.content) { + breaks += countLineBreaks(child.content) + } + } + } + }) + + md.core.ruler.push('vp_source_locs', sourceLocs) +} + +function installInlineWrappers(md: MarkdownIt): void { + interface RuleEntry { + name: string + enabled: boolean + fn: ((state: any, silent: boolean) => boolean) & { [INSTALLED]?: boolean } + alt: string[] + } + const ruler = md.inline.ruler as unknown as { __rules__: RuleEntry[] } + + for (const rule of [...ruler.__rules__]) { + const orig = rule.fn + if (orig[INSTALLED]) continue + const name = rule.name + + const wrapped = (state: any, silent: boolean): boolean => { + if (silent) return orig(state, silent) + + const startIdx: number = state.tokens.length + // state.push() flushes pending text first, so the first token a rule + // appears to emit is often the text run *before* the construct — it + // must not receive this rule's range (the gap-fill pass owns it) + const hadPending: boolean = state.pending.length > 0 + let start: number = state.pos + if (!orig(state, false)) return false + + // the linkify inline rule is entered at the "://", with the scheme + // already consumed into pending — back-scan to recover it + if (name === 'linkify') { + while (start > 0 && /[a-z0-9.+-]/i.test(state.src[start - 1])) start-- + } + + const end: number = state.pos + for (let i = startIdx; i < state.tokens.length; i++) { + const token = state.tokens[i] as PositionedToken + // nested tokenization (link labels) has already stamped inner tokens + // with more precise ranges + if (token[RANGE]) continue + if (i === startIdx && hadPending && token.type === 'text') continue + token[RANGE] = [start, end] + } + return true + } + wrapped[INSTALLED] = true + + md.inline.ruler.at(name, wrapped, { alt: rule.alt }) + } +} + +function installParseWrapper(md: MarkdownIt): void { + const inline = md.inline + const parse = inline.parse.bind(inline) + + inline.parse = (src, mdIt, env, outTokens: PositionedToken[]) => { + parse(src, mdIt, env, outTokens) + + // gap-fill: text runs flushed from pending have no range; they span from + // the previous stamped range to the next one. Runs after ruler2, so + // emphasis retyping and fragment joining are already done. + let prevEnd = 0 + for (let i = 0; i < outTokens.length; i++) { + let range = outTokens[i][RANGE] + if (!range) { + let next = src.length + for (let j = i + 1; j < outTokens.length; j++) { + const r = outTokens[j][RANGE] + if (r) { + next = r[0] + break + } + } + range = outTokens[i][RANGE] = [prevEnd, next] + } + prevEnd = range[1] + } + + // precompute line-relative positions for the tokens we resolve later, + // while src is still pristine (core rules mutate token content) + let lineStarts: number[] | undefined + for (const token of outTokens) { + if (token.type !== 'link_open' && token.type !== 'image') continue + const range = token[RANGE] + if (!range) continue + lineStarts ??= makeLineStarts(src) + const line = findLine(lineStarts, range[0]) + const lineStart = lineStarts[line] + const lineEnd = + line + 1 < lineStarts.length ? lineStarts[line + 1] - 1 : src.length + token[POS] = { + dLine: line, + startInLine: range[0] - lineStart, + lineText: src.slice(lineStart, lineEnd) + } + } + } +} + +function sourceLocs(state: StateCore): void { + const env = state.env as MarkdownEnv + let srcLineStarts: number[] | undefined + + for (const token of state.tokens) { + if (token.type !== 'inline' || !token.children || !token.map) continue + + for (const child of token.children as PositionedToken[]) { + const isImage = child.type === 'image' + if (child.type !== 'link_open' && !isImage) continue + + // no captured position means the token was injected synthetically by a + // core rule (e.g. anchor permalinks) — fabricating a location for it + // would be worse than none + const pos = child[POS] + if (!pos) continue + + const line = token.map[0] + pos.dLine + const loc = resolveLoc(env, line) + + // block parsing only ever strips a prefix per line, so the inline line + // is a suffix of the raw source line; re-align to get the true column + if (pos.startInLine >= 0) { + srcLineStarts ??= makeLineStarts(state.src) + const raw = getLine(state.src, srcLineStarts, line) + if (raw !== undefined) { + if (raw.endsWith(pos.lineText)) { + loc.column = raw.length - pos.lineText.length + pos.startInLine + 1 + } else { + // over-indented content gets spaces prepended instead — fall + // back to searching, and omit the column on ambiguity + const at = raw.indexOf(pos.lineText) + if (at >= 0) loc.column = at + pos.startInLine + 1 + } + } + } + + child.meta ??= {} + child.meta.vpLoc = loc + // the destination as authored (before rebasing and href normalization + // mutate the attr at render time), for dead-link reporting + const dest = child.attrGet(isImage ? 'src' : 'href') + if (dest != null) child.meta.vpRaw = safeDecodeURI(dest) + } + } +} + +function resolveLoc(env: MarkdownEnv, line: number): MarkdownSourceLoc { + const resolved = env.lineMap?.resolve(line) + if (resolved) return { file: resolved.file, line: resolved.line + 1 } + return { file: env.realPath ?? env.path, line: line + 1 } +} + +function safeDecodeURI(str: string): string { + try { + return decodeURI(str) + } catch { + return str + } +} + +function countLineBreaks(str: string): number { + let n = 0 + let i = str.indexOf('\n') + while (i !== -1) { + n++ + i = str.indexOf('\n', i + 1) + } + return n +} + +function makeLineStarts(src: string): number[] { + const starts = [0] + let i = src.indexOf('\n') + while (i !== -1) { + starts.push(i + 1) + i = src.indexOf('\n', i + 1) + } + return starts +} + +/** index of the line containing `offset`, given sorted line-start offsets */ +function findLine(lineStarts: number[], offset: number): number { + let lo = 0 + let hi = lineStarts.length + while (lo < hi) { + const mid = (lo + hi) >>> 1 + if (lineStarts[mid] <= offset) lo = mid + 1 + else hi = mid + } + return Math.max(0, lo - 1) +} + +function getLine( + src: string, + lineStarts: number[], + line: number +): string | undefined { + if (line < 0 || line >= lineStarts.length) return undefined + const start = lineStarts[line] + const end = + line + 1 < lineStarts.length ? lineStarts[line + 1] - 1 : src.length + return src.slice(start, end) +} diff --git a/types/shared.d.ts b/types/shared.d.ts index a35663f9..18e2a726 100644 --- a/types/shared.d.ts +++ b/types/shared.d.ts @@ -607,6 +607,13 @@ export interface MarkdownEnv { * include processing is enabled. */ src?: string + /** + * Maps lines of the rendered source (`src`) back to the physical files + * they came from, set by the include plugin. Token maps stay in rendered + * source coordinates; every position that leaves the markdown layer must + * be translated through this. + */ + lineMap?: MarkdownLineMap /** * The absolute path of the actual source file on disk: the route template * for dynamic routes, or the original file when rewrites are in use. @@ -624,3 +631,31 @@ export interface MarkdownEnv { */ eagerInterpolations?: { expression: string; value: string }[] } + +/** + * A position in a source file, in editor coordinates. + */ +export interface MarkdownSourceLoc { + /** + * Absolute path of the physical file containing the construct — with + * includes, the included file rather than the page. Absent when the render + * has no backing file. + */ + file?: string + /** + * 1-based line in `file`. + */ + line: number + /** + * 1-based column, present when it could be determined exactly. + */ + column?: number +} + +/** + * Maps 0-based lines of the rendered markdown source (`MarkdownEnv.src`) to + * the physical file and 0-based line they came from. + */ +export interface MarkdownLineMap { + resolve(line: number): { file: string; line: number } +} From 62dcdf08062462e7fb43a7d8dc70ebc5da8e688d Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:48:47 +0530 Subject: [PATCH 03/10] feat(markdown)!: map rendered source lines back through includes Include expansion now emits a line map alongside the expanded source (env.lineMap): one segment per contiguous run of lines, composed through nested includes, region/heading/range slices (with the included file's stripped frontmatter height folded in) and the isolation blank lines. Every resolved source position now names the physical file a construct was authored in. This replaces the rebase-marker mechanism outright: relative URLs in included markdown are rebased by resolving the token's position instead of maintaining a marker stack scrubbed out of html_block tokens, which also extends rebasing to lines following an inline include and makes block isolation of own-line markdown includes unconditional (previously both were skipped when markers could not be emitted). BREAKING CHANGE: the `` markers no longer exist in the expanded source (`env.src`). Co-Authored-By: Claude Fable 5 --- .../node/markdown/plugins/include.test.ts | 104 +++++++++ src/node/markdown/lineMap.ts | 179 +++++++++++++++ src/node/markdown/plugins/include.ts | 210 ++++++++++++------ src/node/markdown/plugins/sourcePositions.ts | 17 +- types/shared.d.ts | 11 +- 5 files changed, 447 insertions(+), 74 deletions(-) create mode 100644 src/node/markdown/lineMap.ts diff --git a/__tests__/unit/node/markdown/plugins/include.test.ts b/__tests__/unit/node/markdown/plugins/include.test.ts index 0a8aa757..fadfa216 100644 --- a/__tests__/unit/node/markdown/plugins/include.test.ts +++ b/__tests__/unit/node/markdown/plugins/include.test.ts @@ -533,6 +533,110 @@ describe('node/markdown/plugins/include', () => { expect(html).toContain('href="/abs/target.html"') }) + describe('line map', () => { + async function renderLocs( + src: string, + options: MarkdownOptions = {}, + env: Partial = {} + ) { + const locs: { file?: string; line: number; column?: number }[] = [] + const result = await render( + src, + { + ...options, + config(md) { + md.core.ruler.push('test_capture_locs', (state) => { + for (const token of state.tokens) { + for (const child of token.children ?? []) { + if (child.type === 'link_open' && child.meta?.vpLoc) { + locs.push(child.meta.vpLoc) + } + } + } + }) + } + }, + env + ) + return { ...result, locs } + } + + test('positions resolve into included files and stay exact after them', async () => { + await write( + 'sub/part.md', + '---\nt: 1\n---\nSome text\n[x](./nope)\nMore text\n' + ) + + const { locs } = await renderLocs( + '---\ntitle: x\n---\n\n# Guide\n\n\n\npara [a](./a)\nand [b](./b)\n' + ) + expect(locs).toEqual([ + { file: path.join(root, 'sub/part.md'), line: 5, column: 1 }, + { file: path.join(root, 'index.md'), line: 9, column: 6 }, + { file: path.join(root, 'index.md'), line: 10, column: 5 } + ]) + }) + + test('positions resolve through nested includes', async () => { + await write('a/one.md', 'one\n\n\n') + await write('b/two.md', 'two [t](./deep)\n') + + const { locs } = await renderLocs('\n') + expect(locs).toEqual([ + { file: path.join(root, 'b/two.md'), line: 1, column: 5 } + ]) + }) + + test('region includes point at real editor lines past frontmatter', async () => { + await write( + 'sub/part.md', + '---\nt: 1\n---\nbefore\n\nin [r](./r) region\n\nafter\n' + ) + + const { locs } = await renderLocs( + '\n' + ) + expect(locs).toEqual([ + { file: path.join(root, 'sub/part.md'), line: 6, column: 4 } + ]) + }) + + test('range includes keep frontmatter lines and stay file-accurate', async () => { + await write('sub/part.md', 'one\ntwo [r](./r)\nthree\n') + + const { locs } = await renderLocs( + '\n' + ) + expect(locs).toEqual([ + { file: path.join(root, 'sub/part.md'), line: 2, column: 5 } + ]) + }) + + test('inline includes attribute the splice line to the page', async () => { + await write('sub/part.md', 'spliced [s](./s)\nnext [n](./n)\n') + + const { locs } = await renderLocs( + 'before after\n' + ) + // the first included line merges into the page's line — it keeps the + // page as its file and gets no column, since the stitched line matches + // neither file's authored text; content on the included file's own + // subsequent lines resolves into it exactly + expect(locs).toEqual([ + { file: path.join(root, 'index.md'), line: 1 }, + { file: path.join(root, 'sub/part.md'), line: 2, column: 6 } + ]) + }) + + 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({ + file: path.join(root, 'index.md'), + line: 2 + }) + }) + }) + test('does not rebase destinations resolved from frontmatter', async () => { await write( 'guide/shared/note.md', diff --git a/src/node/markdown/lineMap.ts b/src/node/markdown/lineMap.ts new file mode 100644 index 00000000..20ec1c17 --- /dev/null +++ b/src/node/markdown/lineMap.ts @@ -0,0 +1,179 @@ +import type { MarkdownLineMap } from '../shared' + +/** + * One contiguous run of rendered-source lines that all come from the same + * file. A segment covers the lines from `start` up to the next segment's + * `start` (or the end of the document). + */ +export interface LineMapSegment { + /** first rendered-source line this segment covers (0-based) */ + start: number + /** absolute path of the physical source file */ + file: string + /** line in `file` that `start` corresponds to (0-based) */ + line: number +} + +/** + * Maps 0-based lines of the rendered markdown source (`env.src`) back to the + * physical file and 0-based line they came from. Built by the include plugin + * while expanding `` directives; a page without includes + * gets a single identity segment. + */ +export class LineMap implements MarkdownLineMap { + readonly segments: LineMapSegment[] + /** lines stitched together from more than one source (mid-line splices) */ + private readonly splicedLines: ReadonlySet + + constructor(segments: LineMapSegment[], splicedLines?: ReadonlySet) { + this.segments = segments + this.splicedLines = splicedLines ?? new Set() + } + + resolve(line: number): { file: string; line: number; spliced?: boolean } { + const segments = this.segments + let lo = 0 + let hi = segments.length + while (lo < hi) { + const mid = (lo + hi) >>> 1 + if (segments[mid].start <= line) lo = mid + 1 + else hi = mid + } + const segment = segments[Math.max(0, lo - 1)] + return { + file: segment.file, + line: segment.line + Math.max(0, line - segment.start), + ...(this.splicedLines.has(line) && { spliced: true }) + } + } +} + +/** + * Assembles an output string from mapped chunks, tracking which file each + * output line starts in. A chunk appended mid-line contributes no mapping + * for that line — the line is attributed to whoever started it. + */ +export class MappedBuilder { + private out = '' + private outLine = 0 + private midLine = false + private readonly segments: LineMapSegment[] = [] + private readonly splicedLines = new Set() + + /** + * Appends `text`, whose lines are described by `segments` in the text's + * own 0-based line coordinates. + */ + append( + text: string, + segments: LineMapSegment[], + splicedLines?: ReadonlySet + ): void { + if (!text) return + + // a mid-line append stitches this output line together from more than + // one source, so column positions on it are not meaningful + if (this.midLine) this.splicedLines.add(this.outLine) + if (splicedLines) { + for (const line of splicedLines) + this.splicedLines.add(this.outLine + line) + } + + const breaks = countLineBreaks(text) + for (let i = 0; i < segments.length; i++) { + const segment = segments[i] + const segmentEnd = + i + 1 < segments.length ? segments[i + 1].start : Infinity + // the first line of a mid-line append merges into the current output + // line, which already has an owner + const from = this.midLine ? Math.max(segment.start, 1) : segment.start + if (from >= segmentEnd || from > breaks) continue + this.push({ + start: this.outLine + from, + file: segment.file, + line: segment.line + (from - segment.start) + }) + } + + this.out += text + this.outLine += breaks + this.midLine = text[text.length - 1] !== '\n' + } + + private push(segment: LineMapSegment): void { + const prev = this.segments[this.segments.length - 1] + if (prev) { + // exact continuation of the previous segment — nothing new to record + if ( + prev.file === segment.file && + segment.line - prev.line === segment.start - prev.start + ) { + return + } + if (prev.start === segment.start) { + this.segments[this.segments.length - 1] = segment + return + } + } + this.segments.push(segment) + } + + build(): { + src: string + segments: LineMapSegment[] + splicedLines: Set + } { + return { + src: this.out, + segments: this.segments, + splicedLines: this.splicedLines + } + } +} + +/** + * The sub-list of segments covering lines `[from, to)`, rebased so `from` + * becomes line 0. + */ +export function sliceSegments( + segments: LineMapSegment[], + from: number, + to: number +): LineMapSegment[] { + const out: LineMapSegment[] = [] + for (let i = 0; i < segments.length; i++) { + const segment = segments[i] + const segmentEnd = + i + 1 < segments.length ? segments[i + 1].start : Infinity + const start = Math.max(segment.start, from) + if (start >= Math.min(segmentEnd, to)) continue + out.push({ + start: start - from, + file: segment.file, + line: segment.line + (start - segment.start) + }) + } + return out +} + +export function offsetSegments( + segments: LineMapSegment[], + delta: number +): LineMapSegment[] { + return segments.map((s) => ({ ...s, start: s.start + delta })) +} + +/** resolves a line against raw segments, like `LineMap.resolve` */ +export function resolveInSegments( + segments: LineMapSegment[], + line: number +): { file: string; line: number } { + return new LineMap(segments).resolve(line) +} + +// mirrors markdown-it's newline normalization (`\r\n?|\n`) +const lineBreakRE = /\r\n?|\n/g + +export function countLineBreaks(str: string): number { + return str.match(lineBreakRE)?.length ?? 0 +} diff --git a/src/node/markdown/plugins/include.ts b/src/node/markdown/plugins/include.ts index ec258e3b..1a577ab5 100644 --- a/src/node/markdown/plugins/include.ts +++ b/src/node/markdown/plugins/include.ts @@ -1,11 +1,20 @@ import path from 'node:path' import matter from 'gray-matter' -import { replaceAsync, type MarkdownItAsync } from 'markdown-it-async' +import type { MarkdownItAsync } from 'markdown-it-async' import type { Logger } from 'vite' import { slash, type MarkdownEnv } from '../../shared' import { readTextFile } from '../../utils/fs' +import { + countLineBreaks, + LineMap, + MappedBuilder, + offsetSegments, + resolveInSegments, + sliceSegments, + type LineMapSegment +} from '../lineMap' import { findRegions } from '../regions' export interface Options { @@ -29,18 +38,24 @@ const rangeRE = /\{(\d*),(\d*)\}$/ const regionRE = /#([^\s{]+)$/ const separatorRE = /[\\/]/ const fenceRE = /^ {0,3}(`{3,}|~{3,})/ -const rebaseMarkerRE = /^[ \t]*[ \t]*$/gm -// per-render stacks of included-file directories, driven by the rebase -// markers while rendering -const rebaseStacks = new WeakMap() +interface Expanded { + src: string + /** describes `src` in its own 0-based line coordinates */ + segments: LineMapSegment[] + /** lines of `src` stitched together from more than one source */ + splicedLines: ReadonlySet +} + +const noSplices: ReadonlySet = new Set() /** * Expands `` directives before rendering. Wraps * `renderAsync` so every consumer of the renderer (page rendering, local * search indexing, the content loader and `createMarkdownRenderer` users) - * gets the same expansion. Included files are recorded in `env.includes` - * and the expanded source is exposed as `env.src`. + * gets the same expansion. Included files are recorded in `env.includes`, + * the expanded source is exposed as `env.src`, and `env.lineMap` maps its + * lines back to the physical files they came from. */ export function includePlugin( md: MarkdownItAsync, @@ -55,9 +70,18 @@ export function includePlugin( if (file == null) return renderAsync(src, env) mdEnv!.includes ??= [] - src = await processIncludes(md, srcDir, src, file, mdEnv!, options, logger) - mdEnv!.src = src - return renderAsync(src, env) + const expanded = await processIncludes( + md, + srcDir, + src, + file, + mdEnv!, + options, + logger + ) + mdEnv!.src = expanded.src + mdEnv!.lineMap = new LineMap(expanded.segments, expanded.splicedLines) + return renderAsync(expanded.src, env) } if (options.rebaseRelativeUrls !== false) registerRebaseRules(md) @@ -71,17 +95,36 @@ async function processIncludes( env: MarkdownEnv, options: Options, logger: Pick, - ancestors: string[] = [] -): Promise { - return replaceAsync(src, includeRE, async (...args: string[]) => { - const [m, , rawOffset] = args - let [, m1] = args - if (!m1.length) return m - - const fail = (message: string): string => { + ancestors: string[] = [], + base: LineMapSegment[] = [{ start: 0, file, line: 0 }] +): Promise { + const matches = [...src.matchAll(includeRE)] + if (!matches.length) return { src, segments: base, splicedLines: noSplices } + + const out = new MappedBuilder() + let cursor = 0 + let cursorLine = 0 + + const passthrough = (to: number) => { + if (to <= cursor) return + const text = src.slice(cursor, to) + const breaks = countLineBreaks(text) + out.append(text, sliceSegments(base, cursorLine, cursorLine + breaks + 1)) + cursor = to + cursorLine += breaks + } + + const expandInclude = async ( + m: RegExpExecArray, + directiveLine: number + ): Promise => { + const directive = m[0] + let m1 = m[1] + + const fail = (message: string): Expanded => { if (!options.silent) throw new Error(message) logger.warn(`${message} (in ${file})`) - return '' + return { src: '', segments: [], splicedLines: noSplices } } const range = rangeRE.exec(m1) @@ -95,7 +138,9 @@ async function processIncludes( // leave circular includes unexpanded — only repeats along the ancestor // chain are cycles, the same file may still be included by siblings - if (includePath === file || ancestors.includes(includePath)) return m + if (includePath === file || ancestors.includes(includePath)) { + return undefined + } // record the dependency before reading it, so that creating a missing // file is picked up by the watcher @@ -116,19 +161,36 @@ async function processIncludes( } // for markdown files, if a range is used without a region, the line - // numbers must account for the frontmatter, so it is kept; otherwise - // it is stripped before selecting content + // numbers must account for the frontmatter, so it is kept; otherwise it + // is stripped before selecting content, and the removed height is folded + // into the segments so they keep pointing at real editor lines + let fmOffset = 0 if (path.extname(includePath) === '.md' && (region || !range)) { - content = matter(content, {}).content + const stripped = matter(content, {}).content + fmOffset = countLineBreaks(content) - countLineBreaks(stripped) + content = stripped } let lines = content.split('\n') + let childBase: LineMapSegment[] = [ + { start: 0, file: includePath, line: fmOffset } + ] if (region) { const name = region[1] const regions = findRegions(lines, name) if (regions.length > 0) { - lines = regions.flatMap((r) => lines.slice(r.start, r.end)) + const selected: string[] = [] + childBase = [] + for (const r of regions) { + childBase.push({ + start: selected.length, + file: includePath, + line: fmOffset + r.start + }) + selected.push(...lines.slice(r.start, r.end)) + } + lines = selected } else { // no editor-style region matched — try heading anchors const section = findHeadingSection(md, content, includePath, name, { @@ -140,6 +202,9 @@ async function processIncludes( `Include region or heading "${name}" not found in ${includePath}` ) } + childBase = [ + { start: 0, file: includePath, line: fmOffset + section.start } + ] lines = lines.slice(section.start, section.end) } } @@ -152,11 +217,12 @@ async function processIncludes( `Include range ${range[0]} is out of bounds in ${includePath}` ) } + childBase = sliceSegments(childBase, start - 1, end) lines = lines.slice(start - 1, end) } // recursively process includes in the content - const expanded = await processIncludes( + const child = await processIncludes( md, srcDir, lines.join('\n'), @@ -164,22 +230,52 @@ async function processIncludes( env, options, logger, - [...ancestors, file] + [...ancestors, file], + childBase ) - // wrap included markdown in markers driving the url rebasing at render - // time; they are removed from the output by the html_block rule. Blank - // lines keep them out of adjacent html blocks, and directives that are - // not on a line of their own - inline ones and those inside fences - - // are left unwrapped so the markers can't end up in the output. - const offset = rawOffset as unknown as number - return options.rebaseRelativeUrls !== false && + // wrap included markdown in blank lines so its blocks stay isolated from + // adjacent page content; directives that are not on a line of their own - + // inline ones and those inside fences - are left unwrapped so the blanks + // can't end up inside surrounding constructs. The blank lines belong to + // the include directive's own line. + if ( path.extname(includePath) === '.md' && - isOwnLine(src, offset, m.length) && - !isInsideFence(src, offset) - ? `\n\n${expanded}\n\n` - : expanded - }) + isOwnLine(src, m.index, directive.length) && + !isInsideFence(src, m.index) + ) { + const at = resolveInSegments(base, directiveLine) + const childLineCount = countLineBreaks(child.src) + 1 + return { + src: `\n\n${child.src}\n\n`, + segments: [ + { start: 0, ...at }, + ...offsetSegments(child.segments, 2), + { start: 2 + childLineCount, ...at } + ], + splicedLines: new Set([...child.splicedLines].map((l) => l + 2)) + } + } + return child + } + + for (const m of matches) { + passthrough(m.index) + const expanded = m[1].length + ? await expandInclude(m, cursorLine) + : undefined + if (expanded === undefined) { + // left unexpanded — passes through with the surrounding text + passthrough(m.index + m[0].length) + } else { + out.append(expanded.src, expanded.segments, expanded.splicedLines) + cursor = m.index + m[0].length + cursorLine += countLineBreaks(m[0]) + } + } + passthrough(src.length) + + return out.build() } function findHeadingSection( @@ -237,43 +333,29 @@ function isInsideFence(src: string, offset: number) { } function registerRebaseRules(md: MarkdownItAsync) { - const htmlBlock = md.renderer.rules.html_block! - md.renderer.rules.html_block = (tokens, idx, opts, env, self) => { - const token = tokens[idx] - if (!token.content.includes('`-ed files, which previously reported the including page with a line in the expanded text. Reports print the URL as written plus the resolved page path (`(resolves to /x) in file.md:12:5`), fixing #4992 and #3774's halves of the same complaint, and table-cell links (which had no line at all) and links past the first line of a paragraph now carry exact positions. Carries over the raw-URL reporting and test matrix from #5316. BREAKING CHANGE: `env.links` entries are objects instead of strings and `env.linkLines` is gone; `ignoreDeadLinks` strings, regexes and filter functions now match the link as authored instead of the normalized encoded URL, and filter functions receive a `{ file, line, column, url }` context object instead of the source path string; `MarkdownCompileResult.deadLinks` entries gained `resolved`/`column` and their `url` is now the authored form. The markdown-it rule names `github-alerts`, `snippet` and `vitepress_link_lines` are renamed/replaced by `vp_`-prefixed rules. Co-authored-by: Bjorn Lu <34116392+bluwy@users.noreply.github.com> Co-Authored-By: Claude Fable 5 --- .../node/markdown/plugins/include.test.ts | 2 +- .../unit/node/markdown/plugins/link.test.ts | 14 +-- __tests__/unit/node/markdownToVue.test.ts | 106 +++++++++++++++++- docs/en/reference/site-config.md | 27 +++-- src/node/markdown/plugins/containers.ts | 2 +- src/node/markdown/plugins/link.ts | 46 ++++---- src/node/markdown/plugins/snippet.ts | 2 +- src/node/markdownToVue.ts | 58 +++++++--- src/node/plugin.ts | 11 +- src/node/siteConfig.ts | 31 ++++- types/shared.d.ts | 27 ++++- 11 files changed, 252 insertions(+), 74 deletions(-) diff --git a/__tests__/unit/node/markdown/plugins/include.test.ts b/__tests__/unit/node/markdown/plugins/include.test.ts index fadfa216..772106b0 100644 --- a/__tests__/unit/node/markdown/plugins/include.test.ts +++ b/__tests__/unit/node/markdown/plugins/include.test.ts @@ -654,7 +654,7 @@ describe('node/markdown/plugins/include', () => { // meant there expect(html).toContain('src="./assets/a.png"') expect(html).toContain('href="./other.html"') - expect(env.links).toContain('./other') + expect(env.links!.map((l) => l.url)).toContain('./other.html') // urls authored in the included file still rebase expect(html).toContain('src="./shared/local.png"') }) diff --git a/__tests__/unit/node/markdown/plugins/link.test.ts b/__tests__/unit/node/markdown/plugins/link.test.ts index 119132c3..7845411f 100644 --- a/__tests__/unit/node/markdown/plugins/link.test.ts +++ b/__tests__/unit/node/markdown/plugins/link.test.ts @@ -1,6 +1,7 @@ import { slugify } from '@mdit-vue/shared' import { MarkdownItAsync } from 'markdown-it-async' import { linkPlugin } from 'node/markdown/plugins/link' +import type { MarkdownLink } from 'node/shared' describe('node/markdown/plugins/link', () => { const md = new MarkdownItAsync() @@ -49,17 +50,14 @@ describe('node/markdown/plugins/link', () => { ) }) - test('records source line numbers for collected links', async () => { - const env: { - cleanUrls: boolean - links?: string[] - linkLines?: number[] - } = { cleanUrls: false } + test('collects links with their destination as authored', async () => { + const env: { cleanUrls: boolean; links?: MarkdownLink[] } = { + cleanUrls: false + } await md.renderAsync('Intro\n\n[Missing](./missing.md)\n', env) - expect(env.links).toEqual(['./missing']) - expect(env.linkLines).toEqual([3]) + expect(env.links).toEqual([{ url: './missing.html', raw: './missing.md' }]) }) }) diff --git a/__tests__/unit/node/markdownToVue.test.ts b/__tests__/unit/node/markdownToVue.test.ts index f0f12bb1..bcdafcf4 100644 --- a/__tests__/unit/node/markdownToVue.test.ts +++ b/__tests__/unit/node/markdownToVue.test.ts @@ -36,9 +36,11 @@ describe('node/markdownToVue', () => { const result = await render(src, file) expect(result.deadLinks).toContainEqual({ - url: './missing', + url: './missing.md', + resolved: '/missing', file, - line: 5 + line: 5, + column: 1 }) }) @@ -63,12 +65,108 @@ describe('node/markdownToVue', () => { const result = await render(src, file) expect(result.deadLinks).toContainEqual({ - url: './missing', + url: './missing.md', + resolved: '/missing', file, - line: 8 + line: 8, + column: 1 }) }) + test('reports dead links inside included files at their real location', async () => { + root = await mkdtemp(path.join(tmpdir(), 'vitepress-dead-link-')) + + const file = path.join(root, 'index.md') + const partial = path.join(root, 'part.md') + await writeFile( + partial, + '---\nt: 1\n---\nSome text\n[x](./nope)\nMore text\n' + ) + const src = + '---\ntitle: x\n---\n\n# Guide\n\n\n\npara [a](./a)\nand [b](./b)\n' + await writeFile(file, src) + + const siteConfig = await resolveConfig(root, 'build', 'production') + const render = await createMarkdownToVueRenderFn( + siteConfig.srcDir, + { cache: false }, + '/', + false, + false, + siteConfig + ) + + const result = await render(src, file) + + expect(result.deadLinks).toEqual([ + { url: './nope', resolved: '/nope', file: partial, line: 5, column: 1 }, + { url: './a', resolved: '/a', file, line: 9, column: 6 }, + { url: './b', resolved: '/b', file, line: 10, column: 5 } + ]) + }) + + test('reports the URL as authored alongside the resolved path', async () => { + root = await mkdtemp(path.join(tmpdir(), 'vitepress-dead-link-')) + + const file = path.join(root, 'index.md') + const src = '[a](./a.md)\n\n[b](./b#hash)\n\n[c](./中文.md)\n\n[d](/d)\n' + await writeFile(file, src) + + const siteConfig = await resolveConfig(root, 'build', 'production') + const render = await createMarkdownToVueRenderFn( + siteConfig.srcDir, + { cache: false }, + '/', + false, + false, + siteConfig + ) + + const result = await render(src, file) + + expect( + result.deadLinks.map(({ url, resolved }) => ({ url, resolved })) + ).toEqual([ + { url: './a.md', resolved: '/a' }, + { url: './b#hash', resolved: '/b' }, + { url: './中文.md', resolved: '/中文' }, + { url: '/d', resolved: '/d' } + ]) + }) + + test('passes the authored link and its context to ignoreDeadLinks filters', async () => { + root = await mkdtemp(path.join(tmpdir(), 'vitepress-dead-link-')) + + const file = path.join(root, 'index.md') + const src = '[s](./skip.md)\nand [k](./keep.md)\n' + await writeFile(file, src) + + const siteConfig = await resolveConfig(root, 'build', 'production') + const calls: unknown[] = [] + siteConfig.ignoreDeadLinks = [ + (link, context) => { + calls.push([link, context]) + return link === './skip.md' + } + ] + const render = await createMarkdownToVueRenderFn( + siteConfig.srcDir, + { cache: false }, + '/', + false, + false, + siteConfig + ) + + const result = await render(src, file) + + expect(calls).toEqual([ + ['./skip.md', { file, line: 1, column: 1, url: '/skip' }], + ['./keep.md', { file, line: 2, column: 5, url: '/keep' }] + ]) + expect(result.deadLinks.map((l) => l.url)).toEqual(['./keep.md']) + }) + test('selects included heading sections after frontmatter', async () => { root = await mkdtemp(path.join(tmpdir(), 'vitepress-include-')) diff --git a/docs/en/reference/site-config.md b/docs/en/reference/site-config.md index 7f214127..f435c2b3 100644 --- a/docs/en/reference/site-config.md +++ b/docs/en/reference/site-config.md @@ -520,7 +520,7 @@ export default { ### ignoreDeadLinks -- Type: `boolean | 'localhostLinks' | (string | RegExp | ((link: string, source: string) => boolean))[]` +- Type: `boolean | 'localhostLinks' | (string | RegExp | ((link: string, context: DeadLinkContext) => boolean))[]` - Default: `false` When set to `true`, VitePress will not fail builds due to dead links. @@ -533,20 +533,33 @@ export default { } ``` -It can also be an array of exact url string, regex patterns, or custom filter functions. +It can also be an array of exact url strings, regex patterns, or custom filter functions. These match the link **as authored in the source**, decoded — for example, a link written as `[docs](./guide/index.md)` is matched as `./guide/index.md`. ```ts export default { ignoreDeadLinks: [ - // ignore exact url "/playground" + // ignore links written exactly as "/playground" '/playground', // ignore all localhost links /^https?:\/\/localhost/, - // ignore all links include "/repl/"" + // ignore all links including "/repl/" /\/repl\//, - // custom function, ignore all links include "ignore" - (url) => { - return url.toLowerCase().includes('ignore') + // custom function, ignore all links including "ignore" + (link) => { + return link.toLowerCase().includes('ignore') + } + ] +} +``` + +Filter functions also receive the link's context — the absolute path of the file it was authored in (for links inside [included markdown](../guide/markdown#markdown-file-inclusion), the included file itself), its position, and the URL the check resolved: + +```ts +export default { + ignoreDeadLinks: [ + (link, context) => { + // context: { file: string; line?: number; column?: number; url: string } + return context.file.includes('/generated/') } ] } diff --git a/src/node/markdown/plugins/containers.ts b/src/node/markdown/plugins/containers.ts index 88c7c87b..125e223c 100644 --- a/src/node/markdown/plugins/containers.ts +++ b/src/node/markdown/plugins/containers.ts @@ -195,7 +195,7 @@ export const gitHubAlertsPlugin = ( ) => { const titles = resolveTitlesByLocale(options, locales) - md.core.ruler.after('block', 'github-alerts', (state) => { + md.core.ruler.after('block', 'vp_github_alerts', (state) => { const tokens = state.tokens for (let i = 0; i < tokens.length; i++) { if (tokens[i].type === 'blockquote_open') { diff --git a/src/node/markdown/plugins/link.ts b/src/node/markdown/plugins/link.ts index e9c52105..593b909d 100644 --- a/src/node/markdown/plugins/link.ts +++ b/src/node/markdown/plugins/link.ts @@ -1,10 +1,12 @@ // markdown-it plugin for: // 1. adding target="_blank" to external links // 2. normalize internal links to end with `.html` +// 3. collecting links, with their source positions, for the dead link check import { URL } from 'node:url' import type { MarkdownItAsync } from 'markdown-it-async' +import type Token from 'markdown-it/lib/token.mjs' import { EXTERNAL_URL_RE, @@ -24,20 +26,6 @@ export const linkPlugin = ( base: string, slugify: (str: string) => string ) => { - md.core.ruler.after('inline', 'vitepress_link_lines', (state) => { - for (const token of state.tokens) { - if (token.type !== 'inline' || !token.children || !token.map) continue - - const line = token.map[0] + 1 - for (const child of token.children) { - if (child.type === 'link_open') { - child.meta ??= {} - child.meta.vpLine = line - } - } - } - }) - md.renderer.rules.link_open = ( tokens, idx, @@ -52,6 +40,10 @@ export const linkPlugin = ( token.attrGet('class') !== 'header-anchor' // header anchors are already normalized ) { const hrefAttr = token.attrs![hrefIndex] + // the destination as authored, for dead link reporting - the source + // positions plugin captures it before include rebasing runs; fall back + // to the current href for tokens it did not see + const raw: string = token.meta?.vpRaw ?? safeDecodeURI(hrefAttr[1]) let [url, frag] = hrefAttr[1].split(':~:', 2) hrefAttr[1] = url if (isExternal(url)) { @@ -60,7 +52,7 @@ export const linkPlugin = ( }) // catch localhost links as dead link if (url.replace(EXTERNAL_URL_RE, '').startsWith('//localhost:')) { - pushLink(url, env, token.meta?.vpLine) + pushLink(url, raw, env, token) } hrefAttr[1] = url } else { @@ -77,7 +69,7 @@ export const linkPlugin = ( // skip links to files (other than html/md) treatAsHtml(pathname) ) { - normalizeHref(hrefAttr, env, token.meta?.vpLine) + normalizeHref(hrefAttr, env, raw, token) } else if (url.startsWith('#')) { hrefAttr[1] = decodeURI(normalizeHash(hrefAttr[1])) } @@ -105,7 +97,8 @@ export const linkPlugin = ( function normalizeHref( hrefAttr: [string, string], env: MarkdownEnv, - line?: number + raw: string, + token: Token ) { let url = hrefAttr[1] @@ -143,7 +136,7 @@ export const linkPlugin = ( } // export it for existence check - pushLink(url.replace(/\.html$/, ''), env, line) + pushLink(url, raw, env, token) // markdown-it encodes the uri hrefAttr[1] = decodeURI(url) @@ -153,12 +146,15 @@ export const linkPlugin = ( return str ? encodeURI('#' + slugify(decodeURI(str).slice(1))) : '' } - function pushLink(link: string, env: MarkdownEnv, line?: number) { - const links = env.links || (env.links = []) - links.push(link) - if (line != null) { - const linkLines = env.linkLines || (env.linkLines = []) - linkLines[links.length - 1] = line - } + function pushLink(url: string, raw: string, env: MarkdownEnv, token: Token) { + ;(env.links ??= []).push({ url, raw, loc: token.meta?.vpLoc }) + } +} + +function safeDecodeURI(str: string): string { + try { + return decodeURI(str) + } catch { + return str } } diff --git a/src/node/markdown/plugins/snippet.ts b/src/node/markdown/plugins/snippet.ts index 3fe32cc4..edfd397c 100644 --- a/src/node/markdown/plugins/snippet.ts +++ b/src/node/markdown/plugins/snippet.ts @@ -46,7 +46,7 @@ export function snippetPlugin( options: Options = {}, logger: Pick = console ) { - md.block.ruler.before('fence', 'snippet', createSnippetParser(srcDir)) + md.block.ruler.before('fence', 'vp_snippet', createSnippetParser(srcDir)) const renderFence = md.renderer.rules.fence! md.renderer.rules.fence = createSnippetRenderer(renderFence, options, logger) } diff --git a/src/node/markdownToVue.ts b/src/node/markdownToVue.ts index ceb67235..26a1afdd 100644 --- a/src/node/markdownToVue.ts +++ b/src/node/markdownToVue.ts @@ -22,8 +22,10 @@ import { treatAsHtml, type HeadConfig, type MarkdownEnv, + type MarkdownLink, type PageData } from './shared' +import type { DeadLinkContext } from './siteConfig' import { getGitTimestamp } from './utils/getGitTimestamp' const debug = createDebug('vitepress:md') @@ -49,10 +51,22 @@ let __ts: number export interface MarkdownCompileResult { vueSrc: string pageData: PageData - deadLinks: { url: string; file: string; line?: number }[] + deadLinks: DeadLink[] includes: string[] } +export interface DeadLink { + /** the URL as authored in the source, decoded */ + url: string + /** the site page path it resolved to, for internal links */ + resolved?: string + /** absolute path of the file the link was authored in */ + file: string + /** 1-based position in `file`, when known */ + line?: number + column?: number +} + export function clearCache(relativePath?: string) { if (!relativePath) { cache.clear() @@ -177,7 +191,6 @@ export async function createMarkdownToVueRenderFn( frontmatter = {}, headers = [], includes = [], - linkLines = [], links = [], sfcBlocks, title = '' @@ -185,13 +198,8 @@ export async function createMarkdownToVueRenderFn( // validate data.links const deadLinks: MarkdownCompileResult['deadLinks'] = [] - const recordDeadLink = (url: string, line?: number) => { - deadLinks.push( - line == null ? { url, file: fileOrig } : { url, file: fileOrig, line } - ) - } - function shouldIgnoreDeadLink(url: string) { + function shouldIgnoreDeadLink(link: MarkdownLink, resolved: string) { if (!siteConfig?.ignoreDeadLinks) { return false } @@ -199,22 +207,27 @@ export async function createMarkdownToVueRenderFn( return true } if (siteConfig.ignoreDeadLinks === 'localhostLinks') { - return url.replace(EXTERNAL_URL_RE, '').startsWith('//localhost') + return link.url.replace(EXTERNAL_URL_RE, '').startsWith('//localhost') } + const context: DeadLinkContext = { + file: link.loc?.file ?? fileOrig, + line: link.loc?.line, + column: link.loc?.column, + url: resolved + } return siteConfig.ignoreDeadLinks.some((ignore) => { - if (typeof ignore === 'string') return url === ignore - if (ignore instanceof RegExp) return ignore.test(url) - if (typeof ignore === 'function') return ignore(url, fileOrig) + if (typeof ignore === 'string') return link.raw === ignore + if (ignore instanceof RegExp) return ignore.test(link.raw) + if (typeof ignore === 'function') return ignore(link.raw, context) return false }) } if (links && siteConfig?.ignoreDeadLinks !== true) { const dir = path.dirname(file) - for (const [index, rawUrl] of links.entries()) { - let url = rawUrl - const line = linkLines[index] == null ? undefined : linkLines[index] + for (const link of links) { + let url = link.url const { pathname } = new URL(url, 'http://a.com') if (!treatAsHtml(pathname)) continue @@ -237,6 +250,10 @@ export async function createMarkdownToVueRenderFn( ? undefined : siteConfig?.rewrites.map[resolved + '.md'] + const resolvedPath = EXTERNAL_URL_RE.test(link.url) + ? undefined + : '/' + resolved + if ( (!pages.includes(resolved) || (rewritten != null && rewritten !== resolved + '.md')) && @@ -244,9 +261,16 @@ export async function createMarkdownToVueRenderFn( siteConfig?.publicDir && fs.existsSync(path.join(siteConfig.publicDir, `${resolved}.html`)) ) && - !shouldIgnoreDeadLink(url) + !shouldIgnoreDeadLink(link, resolvedPath ?? link.url) ) { - recordDeadLink(url, line) + const { loc } = link + deadLinks.push({ + url: link.raw, + ...(resolvedPath != null && { resolved: resolvedPath }), + file: loc?.file ?? fileOrig, + ...(loc != null && { line: loc.line }), + ...(loc?.column != null && { column: loc.column }) + }) } } } diff --git a/src/node/plugin.ts b/src/node/plugin.ts index 886bae0d..9592597b 100644 --- a/src/node/plugin.ts +++ b/src/node/plugin.ts @@ -477,15 +477,20 @@ function logDeadLinks( devMode = false ) { const logged = new Set() - deadLinks.forEach(({ url, file, line }, i) => { - const location = line == null ? file : `${file}:${line}` + deadLinks.forEach(({ url, resolved, file, line, column }, i) => { + const location = + line == null + ? file + : `${file}:${line}${column == null ? '' : `:${column}`}` const key = `${location}:::${url}` if (logged.has(key)) return logged.add(key) const prefix = '\n'.repeat(i === 0 ? (devMode ? 1 : 2) : 0) + const target = + resolved && resolved !== url ? ` (resolves to ${c.cyan(resolved)})` : '' logger.warn( c.yellow( - `${prefix}(!) Found dead link ${c.cyan(url)} in file ${c.white(c.dim(location))}` + `${prefix}(!) Found dead link ${c.cyan(url)}${target} in file ${c.white(c.dim(location))}` ) ) }) diff --git a/src/node/siteConfig.ts b/src/node/siteConfig.ts index 2b4d1eb4..418121ae 100644 --- a/src/node/siteConfig.ts +++ b/src/node/siteConfig.ts @@ -82,6 +82,28 @@ export interface TransformPageContext { /** * VitePress config, usually defined in `.vitepress/config.[ext]`. */ +/** + * Where and how a checked link was authored, passed to `ignoreDeadLinks` + * filter functions. + */ +export interface DeadLinkContext { + /** + * Absolute path of the file the link was authored in — for links inside + * ``-ed content, the included file. + */ + file: string + /** + * 1-based position in `file`, when known. + */ + line?: number + column?: number + /** + * The URL the dead link check resolved: the site page path for internal + * links, the normalized URL otherwise. + */ + url: string +} + export interface UserConfig< ThemeConfig = any > extends LocaleSpecificConfig { @@ -234,12 +256,19 @@ export interface UserConfig< * Don't fail builds due to dead links. Accepts `true` (ignore all), * `'localhostLinks'` (only ignore localhost links), or an array of * exact strings, regexes, and custom filter functions. + * + * Strings, regexes and filter functions match the link as authored in the + * source, decoded. Filter functions also receive the link's context: the + * file it was authored in (for links inside ``-ed content, + * the included file), its position, and the URL the check resolved. * @default false */ ignoreDeadLinks?: | boolean | 'localhostLinks' - | (string | RegExp | ((link: string, source: string) => boolean))[] + | ( + string | RegExp | ((link: string, context: DeadLinkContext) => boolean) + )[] /** * Generate `/foo` instead of `/foo.html` for pages and internal * links. Requires matching support from the hosting platform. diff --git a/types/shared.d.ts b/types/shared.d.ts index 028238c9..75af49bc 100644 --- a/types/shared.d.ts +++ b/types/shared.d.ts @@ -590,13 +590,9 @@ export interface MarkdownEnv { */ relativizeUrls?: boolean /** - * The URLs of the links collected from the page for the dead link check. + * The links collected from the page for the dead link check. */ - links?: string[] - /** - * The line numbers at which each of `links` appears in the source. - */ - linkLines?: number[] + links?: MarkdownLink[] /** * The absolute paths of the files inlined via `` and * imported via `<<<` code snippets, used for watch invalidation. @@ -632,6 +628,25 @@ export interface MarkdownEnv { eagerInterpolations?: { expression: string; value: string }[] } +/** + * A link collected while rendering markdown. + */ +export interface MarkdownLink { + /** + * The normalized URL the link renders with, used to resolve the target + * page for the dead link check. + */ + url: string + /** + * The destination as authored in the source, decoded. + */ + raw: string + /** + * Where the link was authored, when known. + */ + loc?: MarkdownSourceLoc +} + /** * A position in a source file, in editor coordinates. */ From 2b62f25b3607fe8c33a37c0f94fd37f5af4920c3 Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:56:56 +0530 Subject: [PATCH 05/10] test(markdown): cover line-map composition directly Co-Authored-By: Claude Fable 5 --- __tests__/unit/node/markdown/lineMap.test.ts | 65 ++++++++++++++++++++ src/node/siteConfig.ts | 6 +- 2 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 __tests__/unit/node/markdown/lineMap.test.ts diff --git a/__tests__/unit/node/markdown/lineMap.test.ts b/__tests__/unit/node/markdown/lineMap.test.ts new file mode 100644 index 00000000..0ab4fa18 --- /dev/null +++ b/__tests__/unit/node/markdown/lineMap.test.ts @@ -0,0 +1,65 @@ +import { + LineMap, + MappedBuilder, + sliceSegments, + type LineMapSegment +} from 'node/markdown/lineMap' + +describe('node/markdown/lineMap', () => { + test('resolve walks segments and extrapolates past the last one', () => { + const map = new LineMap([ + { start: 0, file: 'a.md', line: 0 }, + { start: 3, file: 'b.md', line: 10 }, + { start: 5, file: 'a.md', line: 4 } + ]) + expect(map.resolve(0)).toEqual({ file: 'a.md', line: 0 }) + expect(map.resolve(2)).toEqual({ file: 'a.md', line: 2 }) + expect(map.resolve(3)).toEqual({ file: 'b.md', line: 10 }) + expect(map.resolve(4)).toEqual({ file: 'b.md', line: 11 }) + expect(map.resolve(9)).toEqual({ file: 'a.md', line: 8 }) + }) + + test('builder maps appended chunks and coalesces continuations', () => { + const b = new MappedBuilder() + b.append('one\ntwo\n', [{ start: 0, file: 'a.md', line: 0 }]) + // continues exactly where a.md left off — no new segment needed + b.append('three\n', [{ start: 0, file: 'a.md', line: 2 }]) + b.append('inc-1\ninc-2\n', [{ start: 0, file: 'b.md', line: 7 }]) + const { src, segments } = b.build() + expect(src).toBe('one\ntwo\nthree\ninc-1\ninc-2\n') + expect(segments).toEqual([ + { start: 0, file: 'a.md', line: 0 }, + { start: 3, file: 'b.md', line: 7 } + ]) + }) + + test('mid-line appends leave the line with its starter and mark it spliced', () => { + const b = new MappedBuilder() + b.append('before ', [{ start: 0, file: 'a.md', line: 4 }]) + b.append('x\ny\n', [{ start: 0, file: 'b.md', line: 0 }]) + b.append('tail\n', [{ start: 0, file: 'a.md', line: 5 }]) + const { src, segments, splicedLines } = b.build() + expect(src).toBe('before x\ny\ntail\n') + expect(segments).toEqual([ + { start: 0, file: 'a.md', line: 4 }, + { start: 1, file: 'b.md', line: 1 }, + { start: 2, file: 'a.md', line: 5 } + ]) + expect([...splicedLines]).toEqual([0]) + }) + + test('sliceSegments intersects and rebases', () => { + const segments: LineMapSegment[] = [ + { start: 0, file: 'a.md', line: 0 }, + { start: 4, file: 'b.md', line: 20 } + ] + expect(sliceSegments(segments, 2, 6)).toEqual([ + { start: 0, file: 'a.md', line: 2 }, + { start: 2, file: 'b.md', line: 20 } + ]) + expect(sliceSegments(segments, 4, 5)).toEqual([ + { start: 0, file: 'b.md', line: 20 } + ]) + expect(sliceSegments(segments, 0, 0)).toEqual([]) + }) +}) diff --git a/src/node/siteConfig.ts b/src/node/siteConfig.ts index 418121ae..31381e20 100644 --- a/src/node/siteConfig.ts +++ b/src/node/siteConfig.ts @@ -79,9 +79,6 @@ export interface TransformPageContext { siteConfig: SiteConfig } -/** - * VitePress config, usually defined in `.vitepress/config.[ext]`. - */ /** * Where and how a checked link was authored, passed to `ignoreDeadLinks` * filter functions. @@ -104,6 +101,9 @@ export interface DeadLinkContext { url: string } +/** + * VitePress config, usually defined in `.vitepress/config.[ext]`. + */ export interface UserConfig< ThemeConfig = any > extends LocaleSpecificConfig { From 6886a95953819632c5fdd08f5f3d8a1bc0c0f5a6 Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:03:02 +0530 Subject: [PATCH 06/10] feat: jump from rendered markdown to its source in dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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 --- .../node/markdown/plugins/sourceAttrs.test.ts | 90 +++++++++++++++++++ __tests__/unit/node/markdownToVue.test.ts | 24 +++-- .../node/plugins/localSearchPlugin.test.ts | 3 +- src/client/app/index.ts | 7 ++ src/client/app/openInEditor.ts | 56 ++++++++++++ src/node/markdown/markdown.ts | 15 ++++ src/node/markdown/plugins/containers.ts | 14 ++- src/node/markdown/plugins/preWrapper.ts | 10 ++- src/node/markdown/plugins/sourceAttrs.ts | 53 +++++++++++ src/node/markdownToVue.ts | 10 ++- src/node/plugin.ts | 3 +- src/shared/shared.ts | 3 + types/shared.d.ts | 7 ++ 13 files changed, 279 insertions(+), 16 deletions(-) create mode 100644 __tests__/unit/node/markdown/plugins/sourceAttrs.test.ts create mode 100644 src/client/app/openInEditor.ts create mode 100644 src/node/markdown/plugins/sourceAttrs.ts 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 } /** From ad4774255901a2f8af5aca6c42d870583bb68ac4 Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:06:30 +0530 Subject: [PATCH 07/10] docs: document dev open-in-editor for markdown content Co-Authored-By: Claude Fable 5 --- docs/en/guide/markdown.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/en/guide/markdown.md b/docs/en/guide/markdown.md index 574e9626..3940a8a2 100644 --- a/docs/en/guide/markdown.md +++ b/docs/en/guide/markdown.md @@ -1163,6 +1163,12 @@ export default { } ``` +## Open in Editor + +While running the dev server, hold Alt (Option on macOS) and click any rendered block — a heading, paragraph, code block, container — to open your editor at the markdown source it came from. Content pulled in via [file inclusion](#markdown-file-inclusion) opens the included file at the right line. The editor is picked up from your environment; set the [`LAUNCH_EDITOR`](https://github.com/yyx990803/launch-editor#supported-editors) environment variable to override it. + +This works by stamping rendered elements with `data-v-inspector` source-location attributes in dev. They never appear in builds, and [Vue DevTools](https://devtools.vuejs.org/)' component inspector understands them too — install [`vite-plugin-vue-devtools`](https://devtools.vuejs.org/guide/vite-plugin) for more advanced inspection. Set `markdown.sourceAttrs: false` to keep the dev DOM attribute-free. + ## Advanced Configuration VitePress uses [markdown-it](https://github.com/markdown-it/markdown-it) as the Markdown renderer. A lot of the extensions above are implemented via custom plugins. You can further customize the `markdown-it` instance using the `markdown` option in `.vitepress/config.js`: 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 08/10] 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 } From 5f8677e53f50687e26ca49881dbd0ff5ed3d39f8 Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:43:07 +0530 Subject: [PATCH 09/10] fix(markdown): give stitched lines no position instead of a wrong one An output line assembled from more than one source (page text on the same line as an include directive) matches no single physical line, so any position on it names the wrong place. Rebasing trusted such lines' file attribution and rewrote the page's own relative links against the included file's directory when a mid-line include came first; columns on splice tail lines were measured against the expanded text. Links and attributes on spliced lines now carry no location at all, and the tail line after a mid-line include is marked spliced too. Also from the adversarial review: omit the column when the inline text appears more than once in its raw line (repeated table cells) instead of guessing the first occurrence; give a fully-elided source an identity line map instead of letting resolve() throw; store segment files posix-style so Windows builds compare and report one separator; offset alert paragraph_open attrs past the removed marker like their inline content; keep the page's line map and dev attrs out of excerpt renders; name the including page in dead-link reports for included files (deadLinks[].via); and harden the dev click handler (relative-base endpoint, fetch failure). Co-Authored-By: Claude Fable 5 --- .../node/markdown/plugins/include.test.ts | 45 ++++++++++++++++--- .../node/markdown/plugins/sourceAttrs.test.ts | 29 ++++++++++++ .../markdown/plugins/sourcePositions.test.ts | 10 +++++ __tests__/unit/node/markdownToVue.test.ts | 9 +++- src/client/app/openInEditor.ts | 9 +++- src/node/markdown/lineMap.ts | 9 ++++ src/node/markdown/plugins/containers.ts | 5 +++ src/node/markdown/plugins/frontmatter.ts | 10 ++++- src/node/markdown/plugins/include.ts | 30 ++++++++++--- src/node/markdown/plugins/sourceAttrs.ts | 12 +++-- src/node/markdown/plugins/sourcePositions.ts | 28 +++++++----- src/node/markdownToVue.ts | 5 ++- src/node/plugin.ts | 7 +-- 13 files changed, 175 insertions(+), 33 deletions(-) diff --git a/__tests__/unit/node/markdown/plugins/include.test.ts b/__tests__/unit/node/markdown/plugins/include.test.ts index 1ca005e3..5a7813ee 100644 --- a/__tests__/unit/node/markdown/plugins/include.test.ts +++ b/__tests__/unit/node/markdown/plugins/include.test.ts @@ -612,22 +612,55 @@ describe('node/markdown/plugins/include', () => { ]) }) - test('inline includes attribute the splice line to the page', async () => { + test('stitched lines of inline includes carry no position', async () => { await write('sub/part.md', 'spliced [s](./s)\nnext [n](./n)\n') const { locs } = await renderLocs( 'before after\n' ) - // the first included line merges into the page's line — it keeps the - // page as its file and gets no column, since the stitched line matches - // neither file's authored text; content on the included file's own - // subsequent lines resolves into it exactly + // the first included line merges into the page's line — a stitched + // line matches neither file's authored text, so links on it get no + // location at all; the included file's own subsequent lines resolve + // exactly expect(locs).toEqual([ - { file: path.join(root, 'index.md'), line: 1 }, { file: path.join(root, 'sub/part.md'), line: 2, column: 6 } ]) }) + test('page text after a mid-line include is not misattributed', async () => { + await write('sub/part.md', 'spliced [s](./s.md)') + + const { html, locs } = await renderLocs( + ' after [a](./x.md)\n' + ) + // the page's own link must not be rebased against the included file's + // directory, and neither link gets a position on the stitched line + expect(html).toContain('href="./x.html"') + expect(html).not.toContain('sub/x') + expect(locs).toEqual([]) + }) + + test('page links after a multi-line inline include carry no position', async () => { + await write('sub/part.md', 'T1\nT2\n') + + const { locs } = await renderLocs( + 'before [l](./t.md)\n' + ) + // the tail line's text matches no single physical line - a column + // computed against the expanded text would be wrong + expect(locs).toEqual([]) + }) + + test('a fully elided source still resolves to the page', async () => { + const { env } = await render('', { + include: { silent: true } + }) + expect(env.lineMap!.resolve(0)).toEqual({ + file: path.join(root, 'index.md'), + line: 0 + }) + }) + test('alert bodies inside includes resolve exactly', async () => { await write('sub/part.md', '> [!NOTE]\n> body [x](./x)\n') diff --git a/__tests__/unit/node/markdown/plugins/sourceAttrs.test.ts b/__tests__/unit/node/markdown/plugins/sourceAttrs.test.ts index 441f4207..9fd7bb00 100644 --- a/__tests__/unit/node/markdown/plugins/sourceAttrs.test.ts +++ b/__tests__/unit/node/markdown/plugins/sourceAttrs.test.ts @@ -98,6 +98,35 @@ describe('node/markdown/plugins/sourceAttrs', () => { ) }) + test('alert body paragraphs point past the removed marker line', async () => { + const { vueSrc, file } = await renderPage({ + 'index.md': 'intro\n\n> [!WARNING]\n> line A\n>\n> line B\n' + }) + const at = (line: number) => `data-v-inspector="${rel(file)}:${line}:1"` + // the alert wrapper spans from the marker; its first paragraph does not + expect(vueSrc).toContain(`github-alert" ${at(3)}`) + expect(vueSrc).toContain(`

line A

`) + expect(vueSrc).toContain(`

line B

`) + }) + + test('excerpt renders stay free of source attributes', async () => { + disposeMdItInstance() + const { createMarkdownRenderer } = await import('node/markdown/markdown') + const md = await createMarkdownRenderer('.', { + highlight: (code) => code, + frontmatter: { grayMatterOptions: { excerpt: true } } + }) + const env = { + path: '/docs/page.md', + relativePath: 'page.md', + cleanUrls: false, + emitSourceLoc: true + } as any + await md.renderAsync('---\nt: 1\n---\nfirst para\n\n---\n\nrest\n', env) + expect(env.excerpt).toContain('first para') + expect(env.excerpt).not.toContain('data-v-inspector') + }) + 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 a7428b7f..181be815 100644 --- a/__tests__/unit/node/markdown/plugins/sourcePositions.test.ts +++ b/__tests__/unit/node/markdown/plugins/sourcePositions.test.ts @@ -127,6 +127,16 @@ describe('markdown/plugins/sourcePositions', () => { ]) }) + test('repeated cell text keeps the line and omits the column', async () => { + const links = await collect( + '| a | b |\n|---|---|\n| [x](./x) | [x](./x) |\n' + ) + expect(links.map((l) => l.loc)).toEqual([ + { file: '/docs/page.md', line: 3 }, + { file: '/docs/page.md', line: 3 } + ]) + }) + test('github alert bodies report exact positions', async () => { const links = await collect( '> [!TIP]\n> a [one](./one)\n> b [two](./two)\n' diff --git a/__tests__/unit/node/markdownToVue.test.ts b/__tests__/unit/node/markdownToVue.test.ts index 14394a01..add8a2d3 100644 --- a/__tests__/unit/node/markdownToVue.test.ts +++ b/__tests__/unit/node/markdownToVue.test.ts @@ -102,7 +102,14 @@ describe('node/markdownToVue', () => { const result = await render(src, file) expect(result.deadLinks).toEqual([ - { url: './nope', resolved: '/nope', file: partial, line: 5, column: 1 }, + { + url: './nope', + resolved: '/nope', + file: partial, + line: 5, + column: 1, + via: file + }, { url: './a', resolved: '/a', file, line: 9, column: 6 }, { url: './b', resolved: '/b', file, line: 10, column: 5 } ]) diff --git a/src/client/app/openInEditor.ts b/src/client/app/openInEditor.ts index f4ea06eb..4afad53b 100644 --- a/src/client/app/openInEditor.ts +++ b/src/client/app/openInEditor.ts @@ -47,9 +47,14 @@ export function setupOpenInEditor(): void { e.preventDefault() e.stopPropagation() clear() + // a relative site base resolves page-relatively - the endpoint lives + // at the server root + const base = import.meta.env.BASE_URL fetch( - `${import.meta.env.BASE_URL}__open-in-editor?file=${encodeURIComponent(loc)}` - ) + `${base.startsWith('.') ? '/' : base}__open-in-editor?file=${encodeURIComponent(loc)}` + ).catch(() => { + // dev server gone - nothing to do + }) }, true ) diff --git a/src/node/markdown/lineMap.ts b/src/node/markdown/lineMap.ts index 20ec1c17..6a8ac8ff 100644 --- a/src/node/markdown/lineMap.ts +++ b/src/node/markdown/lineMap.ts @@ -100,6 +100,15 @@ export class MappedBuilder { this.midLine = text[text.length - 1] !== '\n' } + /** + * Marks the line currently being written as stitched from more than one + * source — for output lines whose text no longer matches any single + * physical line (e.g. page text following a mid-line include). + */ + markLineSpliced(): void { + this.splicedLines.add(this.outLine) + } + private push(segment: LineMapSegment): void { const prev = this.segments[this.segments.length - 1] if (prev) { diff --git a/src/node/markdown/plugins/containers.ts b/src/node/markdown/plugins/containers.ts index 4f816999..039a2d84 100644 --- a/src/node/markdown/plugins/containers.ts +++ b/src/node/markdown/plugins/containers.ts @@ -239,6 +239,11 @@ export const gitHubAlertsPlugin = ( ...firstContent.meta, vpLineOffset: removedLines } + // the enclosing paragraph's map also spans the removed marker + const paragraph = tokens[tokens.indexOf(firstContent) - 1] + if (paragraph?.type === 'paragraph_open') { + paragraph.meta = { ...paragraph.meta, vpLineOffset: removedLines } + } } open.type = 'github_alert_open' open.tag = 'div' diff --git a/src/node/markdown/plugins/frontmatter.ts b/src/node/markdown/plugins/frontmatter.ts index 6f122882..ae36a612 100644 --- a/src/node/markdown/plugins/frontmatter.ts +++ b/src/node/markdown/plugins/frontmatter.ts @@ -21,8 +21,16 @@ export function frontmatterPlugin( env.content = content env.frontmatter = { ...(env.frontmatter as object), ...data } + // the excerpt's token maps are excerpt-local — keep the page's line map + // and dev source attributes out of its render env.excerpt = - renderExcerpt && excerpt ? md.render(excerpt, { ...env }) : excerpt + renderExcerpt && excerpt + ? md.render(excerpt, { + ...env, + lineMap: undefined, + emitSourceLoc: false + }) + : excerpt // gray-matter only ever removes lines from the top of the file, so the // difference in line-break counts is exactly the removed line count diff --git a/src/node/markdown/plugins/include.ts b/src/node/markdown/plugins/include.ts index 1a577ab5..e9cc4526 100644 --- a/src/node/markdown/plugins/include.ts +++ b/src/node/markdown/plugins/include.ts @@ -80,7 +80,14 @@ export function includePlugin( logger ) mdEnv!.src = expanded.src - mdEnv!.lineMap = new LineMap(expanded.segments, expanded.splicedLines) + mdEnv!.lineMap = new LineMap( + // everything replaced by nothing leaves no segments - fall back to + // the page itself so resolve() stays total + expanded.segments.length + ? expanded.segments + : [{ start: 0, file, line: 0 }], + expanded.splicedLines + ) return renderAsync(expanded.src, env) } @@ -132,9 +139,13 @@ async function processIncludes( const region = regionRE.exec(m1) if (region) m1 = m1.slice(0, region.index) - const includePath = m1.startsWith('@') - ? path.join(srcDir, m1.slice(separatorRE.test(m1[1]) ? 2 : 1)) - : path.join(path.dirname(file), m1) + // posix-style, so segment files match the vite-style ids in env.path / + // env.realPath on every platform + const includePath = slash( + m1.startsWith('@') + ? path.join(srcDir, m1.slice(separatorRE.test(m1[1]) ? 2 : 1)) + : path.join(path.dirname(file), m1) + ) // leave circular includes unexpanded — only repeats along the ancestor // chain are cycles, the same file may still be included by siblings @@ -144,7 +155,7 @@ async function processIncludes( // record the dependency before reading it, so that creating a missing // file is picked up by the watcher - env.includes!.push(slash(includePath)) + env.includes!.push(includePath) let content: string try { @@ -271,6 +282,15 @@ async function processIncludes( out.append(expanded.src, expanded.segments, expanded.splicedLines) cursor = m.index + m[0].length cursorLine += countLineBreaks(m[0]) + // page text following the directive on the same source line lands on + // an output line whose text matches no single physical line + const sourceLineEnd = src.indexOf('\n', cursor) + if ( + cursor < (sourceLineEnd === -1 ? src.length : sourceLineEnd) && + expanded.src + ) { + out.markLineSpliced() + } } } passthrough(src.length) diff --git a/src/node/markdown/plugins/sourceAttrs.ts b/src/node/markdown/plugins/sourceAttrs.ts index 2829a103..4125bc08 100644 --- a/src/node/markdown/plugins/sourceAttrs.ts +++ b/src/node/markdown/plugins/sourceAttrs.ts @@ -70,9 +70,15 @@ export function sourceAttrsPlugin(md: MarkdownItAsync): void { ) { continue } - const { file, line } = env.lineMap - ? env.lineMap.resolve(token.map[0]) - : { file: env.realPath ?? env.path, line: token.map[0] } + // vpLineOffset: lines a core rule removed from the block's content + // (the github-alerts marker) while its map kept spanning them + const mapLine = token.map[0] + (token.meta?.vpLineOffset ?? 0) + const resolved = env.lineMap?.resolve(mapLine) + // a spliced line is stitched from more than one source — skip rather + // than point at the wrong file + if (resolved?.spliced) continue + const file = resolved ? resolved.file : (env.realPath ?? env.path) + const line = resolved ? resolved.line : mapLine if (!file) continue token.attrSet( SOURCE_LOC_ATTR, diff --git a/src/node/markdown/plugins/sourcePositions.ts b/src/node/markdown/plugins/sourcePositions.ts index ce377bf7..5c85c1d2 100644 --- a/src/node/markdown/plugins/sourcePositions.ts +++ b/src/node/markdown/plugins/sourcePositions.ts @@ -206,19 +206,27 @@ function sourceLocs(state: StateCore): void { const pos = child[POS] if (!pos) continue + // the destination as authored (before rebasing and href normalization + // mutate the attr at render time), for dead-link reporting + child.meta ??= {} + const dest = child.attrGet(isImage ? 'src' : 'href') + if (dest != null) child.meta.vpRaw = safeDecodeURI(dest) + // 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) + // a spliced line is stitched from more than one source — a position on + // it would be wrong in whichever file it names, so it gets none + if (resolved?.spliced) continue + const loc: MarkdownSourceLoc = resolved ? { file: resolved.file, line: resolved.line + 1 } : { file: env.realPath ?? env.path, line: line + 1 } // block parsing only ever strips a prefix per line, so the inline line - // is a suffix of the raw source line; re-align to get the true column. - // A spliced line is stitched from more than one source, so no column - // on it is meaningful in any single file. - if (pos.startInLine >= 0 && !resolved?.spliced) { + // is a suffix of the raw source line; re-align to get the true column + if (pos.startInLine >= 0) { srcLineStarts ??= makeLineStarts(state.src) const raw = getLine(state.src, srcLineStarts, line) if (raw !== undefined) { @@ -226,19 +234,17 @@ function sourceLocs(state: StateCore): void { loc.column = raw.length - pos.lineText.length + pos.startInLine + 1 } else { // over-indented content gets spaces prepended instead — fall - // back to searching, and omit the column on ambiguity + // back to searching, and omit the column when the text appears + // more than once (repeated table cells) const at = raw.indexOf(pos.lineText) - if (at >= 0) loc.column = at + pos.startInLine + 1 + if (at >= 0 && raw.indexOf(pos.lineText, at + 1) === -1) { + loc.column = at + pos.startInLine + 1 + } } } } - child.meta ??= {} child.meta.vpLoc = loc - // the destination as authored (before rebasing and href normalization - // mutate the attr at render time), for dead-link reporting - const dest = child.attrGet(isImage ? 'src' : 'href') - if (dest != null) child.meta.vpRaw = safeDecodeURI(dest) } } } diff --git a/src/node/markdownToVue.ts b/src/node/markdownToVue.ts index 287e90d5..f12031e7 100644 --- a/src/node/markdownToVue.ts +++ b/src/node/markdownToVue.ts @@ -65,6 +65,8 @@ export interface DeadLink { /** 1-based position in `file`, when known */ line?: number column?: number + /** the page that pulled the link in, when `file` is an included file */ + via?: string } export function clearCache(relativePath?: string) { @@ -273,7 +275,8 @@ export async function createMarkdownToVueRenderFn( ...(resolvedPath != null && { resolved: resolvedPath }), file: loc?.file ?? fileOrig, ...(loc != null && { line: loc.line }), - ...(loc?.column != null && { column: loc.column }) + ...(loc?.column != null && { column: loc.column }), + ...(loc?.file != null && loc.file !== fileOrig && { via: fileOrig }) }) } } diff --git a/src/node/plugin.ts b/src/node/plugin.ts index 585d38b2..455eaecb 100644 --- a/src/node/plugin.ts +++ b/src/node/plugin.ts @@ -478,20 +478,21 @@ function logDeadLinks( devMode = false ) { const logged = new Set() - deadLinks.forEach(({ url, resolved, file, line, column }, i) => { + deadLinks.forEach(({ url, resolved, file, line, column, via }, i) => { const location = line == null ? file : `${file}:${line}${column == null ? '' : `:${column}`}` - const key = `${location}:::${url}` + const key = `${location}:::${url}:::${via ?? ''}` if (logged.has(key)) return logged.add(key) const prefix = '\n'.repeat(i === 0 ? (devMode ? 1 : 2) : 0) const target = resolved && resolved !== url ? ` (resolves to ${c.cyan(resolved)})` : '' + const includedBy = via ? ` (via ${c.white(c.dim(via))})` : '' logger.warn( c.yellow( - `${prefix}(!) Found dead link ${c.cyan(url)}${target} in file ${c.white(c.dim(location))}` + `${prefix}(!) Found dead link ${c.cyan(url)}${target} in file ${c.white(c.dim(location))}${includedBy}` ) ) }) From f29546e67119a9452462e4527f5c5c19ed6334f4 Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Mon, 31 Aug 2026 02:21:33 +0530 Subject: [PATCH 10/10] fix(markdown): normalize source-file identities to posix separators The Windows CI leg caught mixed separator styles: slash()ed include paths were compared against native-separator ancestors, which broke the circular-include guard (a -> b -> a expanded one extra level), and line-map files, dead-link reports and the ignoreDeadLinks context mixed C:/ and C:\ forms. The file identity is now slash()ed once at the render entry point, so everything stored, compared or reported - segment files, the ancestor chain, rebase comparisons, deadLinks[].file/.via and filter contexts - uses posix separators on every platform. Co-Authored-By: Claude Fable 5 --- .../node/markdown/plugins/include.test.ts | 20 +++++++++---------- __tests__/unit/node/markdownToVue.test.ts | 17 ++++++++-------- src/node/markdown/plugins/include.ts | 9 ++++++--- src/node/markdown/plugins/sourcePositions.ts | 8 ++++++-- src/node/markdownToVue.ts | 11 ++++++---- 5 files changed, 38 insertions(+), 27 deletions(-) diff --git a/__tests__/unit/node/markdown/plugins/include.test.ts b/__tests__/unit/node/markdown/plugins/include.test.ts index 5a7813ee..b9567b26 100644 --- a/__tests__/unit/node/markdown/plugins/include.test.ts +++ b/__tests__/unit/node/markdown/plugins/include.test.ts @@ -571,9 +571,9 @@ describe('node/markdown/plugins/include', () => { '---\ntitle: x\n---\n\n# Guide\n\n\n\npara [a](./a)\nand [b](./b)\n' ) expect(locs).toEqual([ - { file: path.join(root, 'sub/part.md'), line: 5, column: 1 }, - { file: path.join(root, 'index.md'), line: 9, column: 6 }, - { file: path.join(root, 'index.md'), line: 10, column: 5 } + { file: slash(path.join(root, 'sub/part.md')), line: 5, column: 1 }, + { file: slash(path.join(root, 'index.md')), line: 9, column: 6 }, + { file: slash(path.join(root, 'index.md')), line: 10, column: 5 } ]) }) @@ -583,7 +583,7 @@ describe('node/markdown/plugins/include', () => { const { locs } = await renderLocs('\n') expect(locs).toEqual([ - { file: path.join(root, 'b/two.md'), line: 1, column: 5 } + { file: slash(path.join(root, 'b/two.md')), line: 1, column: 5 } ]) }) @@ -597,7 +597,7 @@ describe('node/markdown/plugins/include', () => { '\n' ) expect(locs).toEqual([ - { file: path.join(root, 'sub/part.md'), line: 6, column: 4 } + { file: slash(path.join(root, 'sub/part.md')), line: 6, column: 4 } ]) }) @@ -608,7 +608,7 @@ describe('node/markdown/plugins/include', () => { '\n' ) expect(locs).toEqual([ - { file: path.join(root, 'sub/part.md'), line: 2, column: 5 } + { file: slash(path.join(root, 'sub/part.md')), line: 2, column: 5 } ]) }) @@ -623,7 +623,7 @@ describe('node/markdown/plugins/include', () => { // location at all; the included file's own subsequent lines resolve // exactly expect(locs).toEqual([ - { file: path.join(root, 'sub/part.md'), line: 2, column: 6 } + { file: slash(path.join(root, 'sub/part.md')), line: 2, column: 6 } ]) }) @@ -656,7 +656,7 @@ describe('node/markdown/plugins/include', () => { include: { silent: true } }) expect(env.lineMap!.resolve(0)).toEqual({ - file: path.join(root, 'index.md'), + file: slash(path.join(root, 'index.md')), line: 0 }) }) @@ -666,14 +666,14 @@ describe('node/markdown/plugins/include', () => { const { locs } = await renderLocs('\n') expect(locs).toEqual([ - { file: path.join(root, 'sub/part.md'), line: 2, column: 8 } + { file: slash(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({ - file: path.join(root, 'index.md'), + file: slash(path.join(root, 'index.md')), line: 2 }) }) diff --git a/__tests__/unit/node/markdownToVue.test.ts b/__tests__/unit/node/markdownToVue.test.ts index add8a2d3..e14a9462 100644 --- a/__tests__/unit/node/markdownToVue.test.ts +++ b/__tests__/unit/node/markdownToVue.test.ts @@ -5,6 +5,7 @@ import path from 'node:path' import { resolveConfig } from 'node/config' import { disposeMdItInstance } from 'node/markdown/markdown' import { createMarkdownToVueRenderFn } from 'node/markdownToVue' +import { slash } from 'node/shared' describe('node/markdownToVue', () => { let root: string | undefined @@ -39,7 +40,7 @@ describe('node/markdownToVue', () => { expect(result.deadLinks).toContainEqual({ url: './missing.md', resolved: '/missing', - file, + file: slash(file), line: 5, column: 1 }) @@ -69,7 +70,7 @@ describe('node/markdownToVue', () => { expect(result.deadLinks).toContainEqual({ url: './missing.md', resolved: '/missing', - file, + file: slash(file), line: 8, column: 1 }) @@ -105,13 +106,13 @@ describe('node/markdownToVue', () => { { url: './nope', resolved: '/nope', - file: partial, + file: slash(partial), line: 5, column: 1, - via: file + via: slash(file) }, - { url: './a', resolved: '/a', file, line: 9, column: 6 }, - { url: './b', resolved: '/b', file, line: 10, column: 5 } + { url: './a', resolved: '/a', file: slash(file), line: 9, column: 6 }, + { url: './b', resolved: '/b', file: slash(file), line: 10, column: 5 } ]) }) @@ -173,8 +174,8 @@ describe('node/markdownToVue', () => { const result = await render(src, file) expect(calls).toEqual([ - ['./skip.md', { file, line: 1, column: 1, url: '/skip' }], - ['./keep.md', { file, line: 2, column: 5, url: '/keep' }] + ['./skip.md', { file: slash(file), line: 1, column: 1, url: '/skip' }], + ['./keep.md', { file: slash(file), line: 2, column: 5, url: '/keep' }] ]) expect(result.deadLinks.map((l) => l.url)).toEqual(['./keep.md']) }) diff --git a/src/node/markdown/plugins/include.ts b/src/node/markdown/plugins/include.ts index e9cc4526..22706790 100644 --- a/src/node/markdown/plugins/include.ts +++ b/src/node/markdown/plugins/include.ts @@ -66,8 +66,11 @@ export function includePlugin( const renderAsync = md.renderAsync.bind(md) md.renderAsync = async (src, env?) => { const mdEnv = env as MarkdownEnv | undefined - const file = mdEnv?.realPath ?? mdEnv?.path - if (file == null) return renderAsync(src, env) + const rawFile = mdEnv?.realPath ?? mdEnv?.path + if (rawFile == null) return renderAsync(src, env) + // one separator style for everything stored, compared or reported - + // segment files, the ancestor chain and includePath are all posix + const file = slash(rawFile) mdEnv!.includes ??= [] const expanded = await processIncludes( @@ -365,7 +368,7 @@ function registerRebaseRules(md: MarkdownItAsync) { // the physical file the construct was authored in, resolved through // the line map — different from the page means it came from an include const sourceFile: string | undefined = token.meta?.vpLoc?.file - if (page && sourceFile && sourceFile !== origin) { + if (page && sourceFile && origin && sourceFile !== slash(origin)) { const attr = rule === 'image' ? 'src' : 'href' const url = token.attrGet(attr) // a destination resolved from `$frontmatter` belongs to the page the diff --git a/src/node/markdown/plugins/sourcePositions.ts b/src/node/markdown/plugins/sourcePositions.ts index 5c85c1d2..3ed1a831 100644 --- a/src/node/markdown/plugins/sourcePositions.ts +++ b/src/node/markdown/plugins/sourcePositions.ts @@ -3,7 +3,7 @@ import type MarkdownIt from 'markdown-it' import type StateCore from 'markdown-it/lib/rules_core/state_core.mjs' import type Token from 'markdown-it/lib/token.mjs' -import type { MarkdownEnv, MarkdownSourceLoc } from '../../shared' +import { slash, type MarkdownEnv, type MarkdownSourceLoc } from '../../shared' // consumed source range of an inline token, [start, end) offsets into the // inline parser's src. Symbols ride on the token objects themselves, so they @@ -220,9 +220,13 @@ function sourceLocs(state: StateCore): void { // it would be wrong in whichever file it names, so it gets none if (resolved?.spliced) continue + const fallbackFile = env.realPath ?? env.path const loc: MarkdownSourceLoc = resolved ? { file: resolved.file, line: resolved.line + 1 } - : { file: env.realPath ?? env.path, line: line + 1 } + : { + file: fallbackFile == null ? undefined : slash(fallbackFile), + line: line + 1 + } // block parsing only ever strips a prefix per line, so the inline line // is a suffix of the raw source line; re-align to get the true column diff --git a/src/node/markdownToVue.ts b/src/node/markdownToVue.ts index f12031e7..bb55d978 100644 --- a/src/node/markdownToVue.ts +++ b/src/node/markdownToVue.ts @@ -60,7 +60,7 @@ export interface DeadLink { url: string /** the site page path it resolved to, for internal links */ resolved?: string - /** absolute path of the file the link was authored in */ + /** absolute path of the file the link was authored in, posix-style */ file: string /** 1-based position in `file`, when known */ line?: number @@ -204,6 +204,8 @@ export async function createMarkdownToVueRenderFn( // validate data.links const deadLinks: MarkdownCompileResult['deadLinks'] = [] + // reported alongside line-map files, which are posix-style + const sourceFile = slash(fileOrig) function shouldIgnoreDeadLink(link: MarkdownLink, resolved: string) { if (!siteConfig?.ignoreDeadLinks) { @@ -217,7 +219,7 @@ export async function createMarkdownToVueRenderFn( } const context: DeadLinkContext = { - file: link.loc?.file ?? fileOrig, + file: link.loc?.file ?? sourceFile, line: link.loc?.line, column: link.loc?.column, url: resolved @@ -273,10 +275,11 @@ export async function createMarkdownToVueRenderFn( deadLinks.push({ url: link.raw, ...(resolvedPath != null && { resolved: resolvedPath }), - file: loc?.file ?? fileOrig, + file: loc?.file ?? sourceFile, ...(loc != null && { line: loc.line }), ...(loc?.column != null && { column: loc.column }), - ...(loc?.file != null && loc.file !== fileOrig && { via: fileOrig }) + ...(loc?.file != null && + loc.file !== sourceFile && { via: sourceFile }) }) } }