mirror of https://github.com/vuejs/vitepress
Merge f29546e671 into 3e681e2ffd
commit
72270f9336
@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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  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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
Loading…
Reference in new issue