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/__tests__/unit/node/markdown/plugins/include.test.ts b/__tests__/unit/node/markdown/plugins/include.test.ts index 0a8aa757..b9567b26 100644 --- a/__tests__/unit/node/markdown/plugins/include.test.ts +++ b/__tests__/unit/node/markdown/plugins/include.test.ts @@ -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 = {} + ) { + 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: 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\n') + await write('b/two.md', 'two [t](./deep)\n') + + const { locs } = await renderLocs('\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\nin [r](./r) region\n\nafter\n' + ) + + const { locs } = await renderLocs( + '\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( + '\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 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( + ' 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: 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('\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"') }) 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/markdown/plugins/sourceAttrs.test.ts b/__tests__/unit/node/markdown/plugins/sourceAttrs.test.ts new file mode 100644 index 00000000..9fd7bb00 --- /dev/null +++ b/__tests__/unit/node/markdown/plugins/sourceAttrs.test.ts @@ -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, + { 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('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('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' }, + { 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/markdown/plugins/sourcePositions.test.ts b/__tests__/unit/node/markdown/plugins/sourcePositions.test.ts new file mode 100644 index 00000000..181be815 --- /dev/null +++ b/__tests__/unit/node/markdown/plugins/sourcePositions.test.ts @@ -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 = {}) { + 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('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() + }) +}) diff --git a/__tests__/unit/node/markdownToVue.test.ts b/__tests__/unit/node/markdownToVue.test.ts index f0f12bb1..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 @@ -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\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) 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/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`: 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/client/app/index.ts b/src/client/app/index.ts index a15e0684..12ee6284 100644 --- a/src/client/app/index.ts +++ b/src/client/app/index.ts @@ -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 } } diff --git a/src/client/app/openInEditor.ts b/src/client/app/openInEditor.ts new file mode 100644 index 00000000..4afad53b --- /dev/null +++ b/src/client/app/openInEditor.ts @@ -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(`[${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 + ) +} diff --git a/src/node/markdown/lineMap.ts b/src/node/markdown/lineMap.ts new file mode 100644 index 00000000..6a8ac8ff --- /dev/null +++ b/src/node/markdown/lineMap.ts @@ -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 `` 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' + } + + /** + * 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 + } { + 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/markdown.ts b/src/node/markdown/markdown.ts index eeb90ced..abd63581 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 { @@ -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 + * ``, 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 @@ -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(/^ `
\n`, + openRender: (tokens, idx) => + `
\n`, closeRender: () => `
\n` }) .use(container, { name: 'raw', - openRender: () => `
\n`, + openRender: (tokens, idx) => + `
\n`, closeRender: () => `
\n` }) .use(container, { @@ -184,7 +188,7 @@ function createCodeGroupOpenRender(md: MarkdownItAsync): RenderRule { } } - return `
${tabs}
\n` + return `
${tabs}
\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 `

${title}

\n` + return `

${title}

\n` } } diff --git a/src/node/markdown/plugins/frontmatter.ts b/src/node/markdown/plugins/frontmatter.ts new file mode 100644 index 00000000..ae36a612 --- /dev/null +++ b/src/node/markdown/plugins/frontmatter.ts @@ -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 = {}) => { + 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 +} diff --git a/src/node/markdown/plugins/include.ts b/src/node/markdown/plugins/include.ts index ec258e3b..22706790 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, @@ -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, - 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) @@ -89,17 +142,23 @@ 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 - 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) - ? `\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]) + // 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('`-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 ``-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/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 a35663f9..e03c4d54 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. @@ -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 + } }