pull/5414/merge
Divyansh Singh 4 days ago committed by GitHub
commit 72270f9336
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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([])
})
})

@ -533,6 +533,152 @@ describe('node/markdown/plugins/include', () => {
expect(html).toContain('href="/abs/target.html"')
})
describe('line map', () => {
async function renderLocs(
src: string,
options: MarkdownOptions = {},
env: Partial<MarkdownEnv> = {}
) {
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<!-- @include: ./sub/part.md -->\n\npara [a](./a)\nand [b](./b)\n'
)
expect(locs).toEqual([
{ 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 }
])
})
test('positions resolve through nested includes', async () => {
await write('a/one.md', 'one\n\n<!-- @include: ../b/two.md -->\n')
await write('b/two.md', 'two [t](./deep)\n')
const { locs } = await renderLocs('<!-- @include: ./a/one.md -->\n')
expect(locs).toEqual([
{ file: slash(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<!-- #region sec -->\nin [r](./r) region\n<!-- #endregion sec -->\nafter\n'
)
const { locs } = await renderLocs(
'<!-- @include: ./sub/part.md#sec -->\n'
)
expect(locs).toEqual([
{ file: slash(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(
'<!-- @include: ./sub/part.md{2,2} -->\n'
)
expect(locs).toEqual([
{ file: slash(path.join(root, 'sub/part.md')), line: 2, column: 5 }
])
})
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 <!-- @include: ./sub/part.md --> after\n'
)
// 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: slash(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(
'<!-- @include: ./sub/part.md --> 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 <!-- @include: ./sub/part.md --> [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: ./missing.md -->', {
include: { silent: true }
})
expect(env.lineMap!.resolve(0)).toEqual({
file: slash(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')
const { locs } = await renderLocs('<!-- @include: ./sub/part.md -->\n')
expect(locs).toEqual([
{ 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: slash(path.join(root, 'index.md')),
line: 2
})
})
})
test('does not rebase destinations resolved from frontmatter', async () => {
await write(
'guide/shared/note.md',
@ -550,7 +696,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"')
})

@ -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' }])
})
})

@ -0,0 +1,145 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { resolveConfig } from 'node/config'
import {
disposeMdItInstance,
type MarkdownOptions
} from 'node/markdown/markdown'
import { createMarkdownToVueRenderFn } from 'node/markdownToVue'
import { slash } from 'node/shared'
describe('node/markdown/plugins/sourceAttrs', () => {
let root: string
beforeEach(async () => {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-source-attrs-'))
})
afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
function rel(file: string) {
return slash(path.relative(process.cwd(), file))
}
async function renderPage(
files: Record<string, string>,
{ dev = true, markdown = {} as MarkdownOptions } = {}
) {
disposeMdItInstance()
for (const [name, text] of Object.entries(files)) {
await writeFile(path.join(root, name), text)
}
const file = path.join(root, 'index.md')
const siteConfig = await resolveConfig(root, 'build', 'production')
const render = await createMarkdownToVueRenderFn(
siteConfig.srcDir,
{ cache: false, ...markdown },
'/',
false,
false,
siteConfig,
dev
)
return { vueSrc: (await render(files['index.md'], file)).vueSrc, file }
}
test('stamps block elements with their source location in dev', async () => {
const { vueSrc, file } = await renderPage({
'index.md':
'---\nt: 1\n---\n\n# Head\n\npara\n\n::: tip\nboxed\n:::\n\n```ts\ncode\n```\n\n> [!NOTE]\n> alert\n'
})
const at = (line: number) => `data-v-inspector="${rel(file)}:${line}:1"`
expect(vueSrc).toContain(at(5)) // heading
expect(vueSrc).toContain(at(7)) // paragraph
expect(vueSrc).toContain(at(9)) // container
expect(vueSrc).toContain(`<div class="language-ts" ${at(13)}`) // fence wrapper
expect(vueSrc).toContain(
`<div class="note custom-block github-alert" ${at(17)}` // gh alert
)
})
test('locations resolve into included files', async () => {
const { vueSrc } = await renderPage({
'part.md': '## From partial\n',
'index.md': '# Page\n\n<!-- @include: ./part.md -->\n'
})
expect(vueSrc).toContain(
`data-v-inspector="${rel(path.join(root, 'part.md'))}:1:1"`
)
})
test('hand-built wrappers re-emit the attribute', async () => {
const { vueSrc, file } = await renderPage(
{
'index.md': '::: v-pre\nvp\n:::\n\n::: raw\nrw\n:::\n\n$$\nx^2\n$$\n'
},
{ markdown: { math: true } }
)
const at = (line: number) => `data-v-inspector="${rel(file)}:${line}:1"`
expect(vueSrc).toContain(`<div v-pre ${at(1)}>`)
expect(vueSrc).toContain(`<div class="vp-raw" ${at(5)}>`)
expect(vueSrc).toContain(`tabindex="0" ${at(9)}`)
})
test('a custom highlight fallback does not double-stamp fences', async () => {
const { vueSrc, file } = await renderPage(
{ 'index.md': '```ts\ncode\n```\n' },
{ markdown: { highlight: (code: string) => code } }
)
const occurrences = vueSrc.match(/data-v-inspector="[^"]*:1:1"/g)
expect(occurrences).toHaveLength(1)
// and it sits on the wrapper, not the inner code element
expect(vueSrc).toContain(
`<div class="language-ts" data-v-inspector="${rel(file)}:1:1">`
)
})
test('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(`<p ${at(4)}>line A</p>`)
expect(vueSrc).toContain(`<p ${at(6)}>line B</p>`)
})
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' },
{ dev: false }
)
expect(vueSrc).not.toContain('data-v-inspector')
})
test('markdown.sourceAttrs: false keeps the dev DOM clean', async () => {
const { vueSrc } = await renderPage(
{ 'index.md': '# Head\n\npara\n' },
{ markdown: { sourceAttrs: false } }
)
expect(vueSrc).not.toContain('data-v-inspector')
})
})

@ -0,0 +1,175 @@
import {
createMarkdownRenderer,
disposeMdItInstance
} from 'node/markdown/markdown'
import type { MarkdownEnv, MarkdownSourceLoc } from 'node/shared'
async function collect(src: string, env: Partial<MarkdownEnv> = {}) {
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 <http://localhost:5173/x>\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('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'
)
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('.', {
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()
})
})

@ -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
@ -30,15 +31,18 @@ describe('node/markdownToVue', () => {
'/',
false,
false,
siteConfig
siteConfig,
false
)
const result = await render(src, file)
expect(result.deadLinks).toContainEqual({
url: './missing',
file,
line: 5
url: './missing.md',
resolved: '/missing',
file: slash(file),
line: 5,
column: 1
})
})
@ -57,18 +61,125 @@ describe('node/markdownToVue', () => {
'/',
false,
false,
siteConfig
siteConfig,
false
)
const result = await render(src, file)
expect(result.deadLinks).toContainEqual({
url: './missing',
file,
line: 8
url: './missing.md',
resolved: '/missing',
file: slash(file),
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<!-- @include: ./part.md -->\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,
false
)
const result = await render(src, file)
expect(result.deadLinks).toEqual([
{
url: './nope',
resolved: '/nope',
file: slash(partial),
line: 5,
column: 1,
via: slash(file)
},
{ url: './a', resolved: '/a', file: slash(file), line: 9, column: 6 },
{ url: './b', resolved: '/b', file: slash(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,
false
)
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,
false
)
const result = await render(src, file)
expect(calls).toEqual([
['./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'])
})
test('selects included heading sections after frontmatter', async () => {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-include-'))
@ -112,7 +223,8 @@ describe('node/markdownToVue', () => {
'/',
false,
false,
siteConfig
siteConfig,
false
)
const result = await render(src, file)
@ -147,7 +259,8 @@ describe('node/markdownToVue', () => {
'/',
false,
false,
siteConfig
siteConfig,
false
)
const result = await render('# Home\n', 'C:/site/docs/en/index.md')
@ -179,7 +292,8 @@ describe('node/markdownToVue', () => {
'/',
false,
false,
siteConfig
siteConfig,
false
)
const result = await render(src, file)

@ -156,7 +156,8 @@ describe('node/plugins/localSearchPlugin', () => {
siteConfig.site.base,
false,
false,
siteConfig
siteConfig,
false
)
const rootFile = path.join(root, 'index.md')

@ -1163,6 +1163,12 @@ export default {
}
```
## Open in Editor
While running the dev server, hold <kbd>Alt</kbd> (<kbd>Option</kbd> 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`:

@ -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/')
}
]
}

@ -119,6 +119,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 }
}

@ -0,0 +1,61 @@
// dev-only: alt+click on rendered markdown content jumps the editor to the
// source location carried by the `data-v-inspector` attributes (see the
// sourceAttrs markdown plugin), through the dev server's built-in
// `/__open-in-editor` endpoint. Needs no plugins — with the Vue DevTools
// component inspector active, its own overlay takes over instead.
const ATTR = 'data-v-inspector'
export function setupOpenInEditor(): void {
let target: HTMLElement | undefined
let previousOutline = ''
const clear = () => {
if (target) {
target.style.outline = previousOutline
target = undefined
}
}
const find = (el: EventTarget | null) =>
el instanceof Element ? el.closest<HTMLElement>(`[${ATTR}]`) : null
window.addEventListener('mousemove', (e) => {
if (!e.altKey) return clear()
const el = find(e.target)
if (el === target) return
clear()
if (el) {
target = el
previousOutline = el.style.outline
el.style.outline = '1px solid var(--vp-c-brand-1, #3451b2)'
}
})
window.addEventListener('keyup', (e) => {
if (e.key === 'Alt') clear()
})
window.addEventListener('blur', clear)
window.addEventListener(
'click',
(e) => {
if (!e.altKey) return
// the vue devtools inspector overlay handles clicks itself while active
if ((window as any).__VUE_INSPECTOR__?.enabled) return
const loc = find(e.target)?.getAttribute(ATTR)
if (!loc) return
e.preventDefault()
e.stopPropagation()
clear()
// a relative site base resolves page-relatively - the endpoint lives
// at the server root
const base = import.meta.env.BASE_URL
fetch(
`${base.startsWith('.') ? '/' : base}__open-in-editor?file=${encodeURIComponent(loc)}`
).catch(() => {
// dev server gone - nothing to do
})
},
true
)
}

@ -0,0 +1,188 @@
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 `<!-- @include -->` 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<number>
constructor(segments: LineMapSegment[], splicedLines?: ReadonlySet<number>) {
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<number>()
/**
* Appends `text`, whose lines are described by `segments` in the text's
* own 0-based line coordinates.
*/
append(
text: string,
segments: LineMapSegment[],
splicedLines?: ReadonlySet<number>
): 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'
}
/**
* 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) {
// 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<number>
} {
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
}

@ -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 {
@ -66,6 +64,8 @@ import {
snippetPlugin,
type Options as SnippetPluginOptions
} from './plugins/snippet'
import { renderSourceLocAttr, sourceAttrsPlugin } from './plugins/sourceAttrs'
import { sourcePositionsPlugin } from './plugins/sourcePositions'
import { tablePlugin } from './plugins/table'
export type { Header } from '../shared'
@ -350,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
* `<!--@include-->`, 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.<index>.markdown` entries from the site config into
@ -536,9 +547,15 @@ export async function createMarkdownRenderer(
}
const origMathBlock = md.renderer.rules.math_block!
md.renderer.rules.math_block = function (...args) {
// mathjax's renderer ignores token attrs - re-emit the source
// location alongside the v-pre/tabindex injection
const sourceLocAttr = renderSourceLocAttr(md!, args[0][args[1]])
return origMathBlock
.apply(this, args)
.replace(/^<mjx-container /, '<mjx-container v-pre tabindex="0" ')
.replace(
/^<mjx-container /,
`<mjx-container v-pre tabindex="0"${sourceLocAttr} `
)
}
} catch (error) {
throw new Error(
@ -583,6 +600,13 @@ 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)
if (options.sourceAttrs !== false) {
sourceAttrsPlugin(md)
}
// apply user config
if (options.config) {
await options.config(md)

@ -8,7 +8,9 @@ import type {
MarkdownEnv,
MarkdownLocaleOptions
} from '../../shared'
import { countLineBreaks } from '../lineMap'
import { extractTitle } from './preWrapper'
import { renderSourceLocAttr } from './sourceAttrs'
export type { ContainerOptions } from '../../shared'
@ -41,12 +43,14 @@ export const containerPlugin = (
// explicitly escape Vue syntax
.use(container, {
name: 'v-pre',
openRender: () => `<div v-pre>\n`,
openRender: (tokens, idx) =>
`<div v-pre${renderSourceLocAttr(md, tokens[idx])}>\n`,
closeRender: () => `</div>\n`
})
.use(container, {
name: 'raw',
openRender: () => `<div class="vp-raw">\n`,
openRender: (tokens, idx) =>
`<div class="vp-raw"${renderSourceLocAttr(md, tokens[idx])}>\n`,
closeRender: () => `</div>\n`
})
.use(container, {
@ -184,7 +188,7 @@ function createCodeGroupOpenRender(md: MarkdownItAsync): RenderRule {
}
}
return `<div class="vp-code-group"><div class="tabs">${tabs}</div><div class="blocks">\n`
return `<div class="vp-code-group"${renderSourceLocAttr(md, tokens[idx])}><div class="tabs">${tabs}</div><div class="blocks">\n`
}
}
@ -195,7 +199,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') {
@ -222,9 +226,25 @@ 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
}
// 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'
open.meta = { title, type }
@ -235,6 +255,6 @@ export const gitHubAlertsPlugin = (
})
md.renderer.rules.github_alert_open = function (tokens, idx) {
const { title, type } = tokens[idx].meta
return `<div class="${type} custom-block github-alert"><p class="custom-block-title">${title}</p>\n`
return `<div class="${type} custom-block github-alert"${renderSourceLocAttr(md, tokens[idx])}><p class="custom-block-title">${title}</p>\n`
}
}

@ -0,0 +1,47 @@
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<string, unknown> = {}) => {
const { data, content, excerpt = '' } = matter(src, grayMatterOptions)
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,
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
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
}

@ -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]*<!-- @include-(?:start: (.*)|end) -->[ \t]*$/gm
// per-render stacks of included-file directories, driven by the rebase
// markers while rendering
const rebaseStacks = new WeakMap<object, string[]>()
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<number>
}
const noSplices: ReadonlySet<number> = new Set()
/**
* Expands `<!-- @include: path -->` 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,
@ -51,13 +66,32 @@ 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 ??= []
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(
// 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)
}
if (options.rebaseRelativeUrls !== false) registerRebaseRules(md)
@ -71,17 +105,36 @@ async function processIncludes(
env: MarkdownEnv,
options: Options,
logger: Pick<Logger, 'warn'>,
ancestors: string[] = []
): Promise<string> {
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<Expanded> {
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<Expanded | undefined> => {
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)
@ -89,17 +142,23 @@ async function processIncludes(
const region = regionRE.exec(m1)
if (region) m1 = m1.slice(0, region.index)
const includePath = m1.startsWith('@')
// 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
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
env.includes!.push(slash(includePath))
env.includes!.push(includePath)
let content: string
try {
@ -116,19 +175,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 +216,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 +231,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 +244,61 @@ 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)
? `<!-- @include-start: ${path.dirname(includePath)} -->\n\n${expanded}\n\n<!-- @include-end -->`
: 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])
// 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)
return out.build()
}
function findHeadingSection(
@ -237,43 +356,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('<!-- @include-')) {
return htmlBlock(tokens, idx, opts, env, self)
}
// markers are emitted on their own lines, but an adjacent html block can
// still absorb them into its token, so they are matched anywhere in the
// content and removed from it
token.content = token.content.replace(rebaseMarkerRE, (_, dir?: string) => {
let stack = rebaseStacks.get(env)
if (!stack) rebaseStacks.set(env, (stack = []))
if (dir == null) stack.pop()
else stack.push(dir)
return ''
})
return token.content.trim() ? htmlBlock(tokens, idx, opts, env, self) : ''
}
for (const rule of ['image', 'link_open'] as const) {
const render =
md.renderer.rules[rule] ??
((tokens, idx, opts, _env, self) => self.renderToken(tokens, idx, opts))
md.renderer.rules[rule] = (tokens, idx, opts, env, self) => {
const dir = rebaseStacks.get(env)?.at(-1)
const file = (env as MarkdownEnv).path
if (dir && file) {
const token = tokens[idx]
const mdEnv = env as MarkdownEnv
const page = mdEnv.path
const origin = mdEnv.realPath ?? mdEnv.path
// 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 && 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
// frontmatter came from, not to the included file
if (url?.[0] === '.' && !token.meta?.frontmatterDest) {
const rebased = slash(
path.join(path.relative(path.dirname(file), dir), url)
path.join(
path.relative(path.dirname(page), path.dirname(sourceFile)),
url
)
)
token.attrSet(attr, rebased[0] === '.' ? rebased : `./${rebased}`)
}

@ -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
}
}

@ -1,6 +1,7 @@
import type { MarkdownItAsync } from 'markdown-it-async'
import type { MarkdownEnv, MarkdownLocaleOptions } from '../../shared'
import { popSourceLocAttr } from './sourceAttrs'
export interface Options {
codeCopyButton: { tooltipText: string; copiedText: string }
@ -41,8 +42,14 @@ 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 moves onto the wrapper - popped off the token because a
// custom `highlight` may fall back to markdown-it's default fence
// renderer, which does render token attrs
const sourceLocAttr = popSourceLocAttr(md, token)
return (
`<div class="language-${lang}${active}">` +
`<div class="language-${lang}${active}"${sourceLocAttr}>` +
`<button title="${tooltipText}" data-copied="${copiedText}" class="copy"></button>` +
`<span class="lang">${label}</span>` +
fence(...args) +

@ -46,7 +46,7 @@ export function snippetPlugin(
options: Options = {},
logger: Pick<Logger, 'warn'> = 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)
}

@ -0,0 +1,89 @@
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'
/**
* 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.
*/
/**
* For renderers that build their markup by hand and would otherwise drop
* `token.attrs`: the source-location attribute rendered as ` name="value"`,
* or an empty string.
*/
export function renderSourceLocAttr(
md: Pick<MarkdownIt, 'utils'>,
token: Token
): string {
const loc = token.attrGet(SOURCE_LOC_ATTR)
return loc ? ` ${SOURCE_LOC_ATTR}="${md.utils.escapeHtml(loc)}"` : ''
}
/**
* Like `renderSourceLocAttr`, but also removes the attribute from the token
* for wrappers whose inner renderer may fall back to a default rule that
* renders token attrs, which would emit the location twice.
*/
export function popSourceLocAttr(
md: Pick<MarkdownIt, 'utils'>,
token: Token
): string {
const rendered = renderSourceLocAttr(md, token)
const index = token.attrIndex(SOURCE_LOC_ATTR)
if (index >= 0) token.attrs!.splice(index, 1)
return rendered
}
export function sourceAttrsPlugin(md: MarkdownItAsync): void {
md.core.ruler.push('vp_source_attrs', (state) => {
const env = state.env as MarkdownEnv
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
}
// 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,
`${slash(path.relative(process.cwd(), file))}:${line + 1}:1`
)
}
})
}

@ -0,0 +1,306 @@
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 { 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
// 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<symbol, boolean>
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
// 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 fallbackFile = env.realPath ?? env.path
const loc: MarkdownSourceLoc = resolved
? { file: resolved.file, line: resolved.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
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 when the text appears
// more than once (repeated table cells)
const at = raw.indexOf(pos.lineText)
if (at >= 0 && raw.indexOf(pos.lineText, at + 1) === -1) {
loc.column = at + pos.startInLine + 1
}
}
}
}
child.meta.vpLoc = loc
}
}
}
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)
}

@ -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,24 @@ 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, posix-style */
file: string
/** 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) {
if (!relativePath) {
cache.clear()
@ -105,7 +121,8 @@ export async function createMarkdownToVueRenderFn(
base: string,
includeLastUpdatedData: boolean,
cleanUrls: boolean,
siteConfig: SiteConfig
siteConfig: SiteConfig,
dev: boolean
) {
const md = await createMarkdownRenderer(
srcDir,
@ -130,7 +147,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) {
@ -162,7 +179,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 {
@ -174,29 +194,20 @@ export async function createMarkdownToVueRenderFn(
throw e
}
const {
content,
frontmatter = {},
headers = [],
includes = [],
linkLines = [],
links = [],
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'] = []
const recordDeadLink = (url: string, line?: number) => {
deadLinks.push(
line == null ? { url, file: fileOrig } : { url, file: fileOrig, line }
)
}
// reported alongside line-map files, which are posix-style
const sourceFile = slash(fileOrig)
function shouldIgnoreDeadLink(url: string) {
function shouldIgnoreDeadLink(link: MarkdownLink, resolved: string) {
if (!siteConfig?.ignoreDeadLinks) {
return false
}
@ -204,25 +215,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 ?? sourceFile,
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] + contentLineOffset
for (const link of links) {
let url = link.url
const { pathname } = new URL(url, 'http://a.com')
if (!treatAsHtml(pathname)) continue
@ -245,6 +258,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')) &&
@ -252,9 +269,18 @@ 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 ?? sourceFile,
...(loc != null && { line: loc.line }),
...(loc?.column != null && { column: loc.column }),
...(loc?.file != null &&
loc.file !== sourceFile && { via: sourceFile })
})
}
}
}
@ -390,10 +416,6 @@ const inferDescription = (frontmatter: Record<string, any>) => {
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

@ -136,7 +136,8 @@ export async function createVitePressPlugin(
site.base,
lastUpdated ?? false,
cleanUrls ?? false,
siteConfig
siteConfig,
config.command === 'serve'
)
},
@ -477,15 +478,21 @@ function logDeadLinks(
devMode = false
) {
const logged = new Set<string>()
deadLinks.forEach(({ url, file, line }, i) => {
const location = line == null ? file : `${file}:${line}`
const key = `${location}:::${url}`
deadLinks.forEach(({ url, resolved, file, line, column, via }, i) => {
const location =
line == null
? file
: `${file}:${line}${column == null ? '' : `:${column}`}`
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)} in file ${c.white(c.dim(location))}`
`${prefix}(!) Found dead link ${c.cyan(url)}${target} in file ${c.white(c.dim(location))}${includedBy}`
)
)
})

@ -79,6 +79,28 @@ export interface TransformPageContext<ThemeConfig = any> {
siteConfig: SiteConfig<ThemeConfig>
}
/**
* 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
* `<!--@include-->`-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
}
/**
* VitePress config, usually defined in `.vitepress/config.[ext]`.
*/
@ -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 `<!--@include-->`-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.

@ -18,7 +18,10 @@ export type {
LocaleConfig,
LocaleSpecificConfig,
MarkdownEnv,
MarkdownLineMap,
MarkdownLink,
MarkdownLocaleOptions,
MarkdownSourceLoc,
PageData,
PageDataPayload,
Route,

78
types/shared.d.ts vendored

@ -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 `<!--@include-->` and
* imported via `<<<` code snippets, used for watch invalidation.
@ -607,6 +603,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.
@ -623,4 +626,67 @@ 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
}
/**
* 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.
*/
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
/**
* Set when the line was stitched together from more than one source
* (a mid-line include splice) column positions on it are not
* meaningful in any single file.
*/
spliced?: boolean
}
}

Loading…
Cancel
Save