fix(markdown): normalize source-file identities to posix separators

The Windows CI leg caught mixed separator styles: slash()ed include paths
were compared against native-separator ancestors, which broke the
circular-include guard (a -> b -> a expanded one extra level), and
line-map files, dead-link reports and the ignoreDeadLinks context mixed
C:/ and C:\ forms. The file identity is now slash()ed once at the
render entry point, so everything stored, compared or reported - segment
files, the ancestor chain, rebase comparisons, deadLinks[].file/.via and
filter contexts - uses posix separators on every platform.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/md-sourcemaps
Divyansh Singh 1 week ago
parent 5f8677e53f
commit f29546e671

@ -571,9 +571,9 @@ describe('node/markdown/plugins/include', () => {
'---\ntitle: x\n---\n\n# Guide\n\n<!-- @include: ./sub/part.md -->\n\npara [a](./a)\nand [b](./b)\n'
)
expect(locs).toEqual([
{ file: path.join(root, 'sub/part.md'), line: 5, column: 1 },
{ file: path.join(root, 'index.md'), line: 9, column: 6 },
{ file: path.join(root, 'index.md'), line: 10, column: 5 }
{ file: slash(path.join(root, 'sub/part.md')), line: 5, column: 1 },
{ file: slash(path.join(root, 'index.md')), line: 9, column: 6 },
{ file: slash(path.join(root, 'index.md')), line: 10, column: 5 }
])
})
@ -583,7 +583,7 @@ describe('node/markdown/plugins/include', () => {
const { locs } = await renderLocs('<!-- @include: ./a/one.md -->\n')
expect(locs).toEqual([
{ file: path.join(root, 'b/two.md'), line: 1, column: 5 }
{ file: slash(path.join(root, 'b/two.md')), line: 1, column: 5 }
])
})
@ -597,7 +597,7 @@ describe('node/markdown/plugins/include', () => {
'<!-- @include: ./sub/part.md#sec -->\n'
)
expect(locs).toEqual([
{ file: path.join(root, 'sub/part.md'), line: 6, column: 4 }
{ file: slash(path.join(root, 'sub/part.md')), line: 6, column: 4 }
])
})
@ -608,7 +608,7 @@ describe('node/markdown/plugins/include', () => {
'<!-- @include: ./sub/part.md{2,2} -->\n'
)
expect(locs).toEqual([
{ file: path.join(root, 'sub/part.md'), line: 2, column: 5 }
{ file: slash(path.join(root, 'sub/part.md')), line: 2, column: 5 }
])
})
@ -623,7 +623,7 @@ describe('node/markdown/plugins/include', () => {
// location at all; the included file's own subsequent lines resolve
// exactly
expect(locs).toEqual([
{ file: path.join(root, 'sub/part.md'), line: 2, column: 6 }
{ file: slash(path.join(root, 'sub/part.md')), line: 2, column: 6 }
])
})
@ -656,7 +656,7 @@ describe('node/markdown/plugins/include', () => {
include: { silent: true }
})
expect(env.lineMap!.resolve(0)).toEqual({
file: path.join(root, 'index.md'),
file: slash(path.join(root, 'index.md')),
line: 0
})
})
@ -666,14 +666,14 @@ describe('node/markdown/plugins/include', () => {
const { locs } = await renderLocs('<!-- @include: ./sub/part.md -->\n')
expect(locs).toEqual([
{ file: path.join(root, 'sub/part.md'), line: 2, column: 8 }
{ file: slash(path.join(root, 'sub/part.md')), line: 2, column: 8 }
])
})
test('pages without includes get an identity line map', async () => {
const { env } = await render('# Hi\n\n[a](./a)\n')
expect(env.lineMap!.resolve(2)).toEqual({
file: path.join(root, 'index.md'),
file: slash(path.join(root, 'index.md')),
line: 2
})
})

@ -5,6 +5,7 @@ import path from 'node:path'
import { resolveConfig } from 'node/config'
import { disposeMdItInstance } from 'node/markdown/markdown'
import { createMarkdownToVueRenderFn } from 'node/markdownToVue'
import { slash } from 'node/shared'
describe('node/markdownToVue', () => {
let root: string | undefined
@ -39,7 +40,7 @@ describe('node/markdownToVue', () => {
expect(result.deadLinks).toContainEqual({
url: './missing.md',
resolved: '/missing',
file,
file: slash(file),
line: 5,
column: 1
})
@ -69,7 +70,7 @@ describe('node/markdownToVue', () => {
expect(result.deadLinks).toContainEqual({
url: './missing.md',
resolved: '/missing',
file,
file: slash(file),
line: 8,
column: 1
})
@ -105,13 +106,13 @@ describe('node/markdownToVue', () => {
{
url: './nope',
resolved: '/nope',
file: partial,
file: slash(partial),
line: 5,
column: 1,
via: file
via: slash(file)
},
{ url: './a', resolved: '/a', file, line: 9, column: 6 },
{ url: './b', resolved: '/b', file, line: 10, column: 5 }
{ url: './a', resolved: '/a', file: slash(file), line: 9, column: 6 },
{ url: './b', resolved: '/b', file: slash(file), line: 10, column: 5 }
])
})
@ -173,8 +174,8 @@ describe('node/markdownToVue', () => {
const result = await render(src, file)
expect(calls).toEqual([
['./skip.md', { file, line: 1, column: 1, url: '/skip' }],
['./keep.md', { file, line: 2, column: 5, url: '/keep' }]
['./skip.md', { file: slash(file), line: 1, column: 1, url: '/skip' }],
['./keep.md', { file: slash(file), line: 2, column: 5, url: '/keep' }]
])
expect(result.deadLinks.map((l) => l.url)).toEqual(['./keep.md'])
})

@ -66,8 +66,11 @@ export function includePlugin(
const renderAsync = md.renderAsync.bind(md)
md.renderAsync = async (src, env?) => {
const mdEnv = env as MarkdownEnv | undefined
const file = mdEnv?.realPath ?? mdEnv?.path
if (file == null) return renderAsync(src, env)
const rawFile = mdEnv?.realPath ?? mdEnv?.path
if (rawFile == null) return renderAsync(src, env)
// one separator style for everything stored, compared or reported -
// segment files, the ancestor chain and includePath are all posix
const file = slash(rawFile)
mdEnv!.includes ??= []
const expanded = await processIncludes(
@ -365,7 +368,7 @@ function registerRebaseRules(md: MarkdownItAsync) {
// the physical file the construct was authored in, resolved through
// the line map — different from the page means it came from an include
const sourceFile: string | undefined = token.meta?.vpLoc?.file
if (page && sourceFile && sourceFile !== origin) {
if (page && sourceFile && origin && sourceFile !== slash(origin)) {
const attr = rule === 'image' ? 'src' : 'href'
const url = token.attrGet(attr)
// a destination resolved from `$frontmatter` belongs to the page the

@ -3,7 +3,7 @@ import type MarkdownIt from 'markdown-it'
import type StateCore from 'markdown-it/lib/rules_core/state_core.mjs'
import type Token from 'markdown-it/lib/token.mjs'
import type { MarkdownEnv, MarkdownSourceLoc } from '../../shared'
import { slash, type MarkdownEnv, type MarkdownSourceLoc } from '../../shared'
// consumed source range of an inline token, [start, end) offsets into the
// inline parser's src. Symbols ride on the token objects themselves, so they
@ -220,9 +220,13 @@ function sourceLocs(state: StateCore): void {
// it would be wrong in whichever file it names, so it gets none
if (resolved?.spliced) continue
const fallbackFile = env.realPath ?? env.path
const loc: MarkdownSourceLoc = resolved
? { file: resolved.file, line: resolved.line + 1 }
: { file: env.realPath ?? env.path, line: line + 1 }
: {
file: fallbackFile == null ? undefined : slash(fallbackFile),
line: line + 1
}
// block parsing only ever strips a prefix per line, so the inline line
// is a suffix of the raw source line; re-align to get the true column

@ -60,7 +60,7 @@ export interface DeadLink {
url: string
/** the site page path it resolved to, for internal links */
resolved?: string
/** absolute path of the file the link was authored in */
/** absolute path of the file the link was authored in, posix-style */
file: string
/** 1-based position in `file`, when known */
line?: number
@ -204,6 +204,8 @@ export async function createMarkdownToVueRenderFn(
// validate data.links
const deadLinks: MarkdownCompileResult['deadLinks'] = []
// reported alongside line-map files, which are posix-style
const sourceFile = slash(fileOrig)
function shouldIgnoreDeadLink(link: MarkdownLink, resolved: string) {
if (!siteConfig?.ignoreDeadLinks) {
@ -217,7 +219,7 @@ export async function createMarkdownToVueRenderFn(
}
const context: DeadLinkContext = {
file: link.loc?.file ?? fileOrig,
file: link.loc?.file ?? sourceFile,
line: link.loc?.line,
column: link.loc?.column,
url: resolved
@ -273,10 +275,11 @@ export async function createMarkdownToVueRenderFn(
deadLinks.push({
url: link.raw,
...(resolvedPath != null && { resolved: resolvedPath }),
file: loc?.file ?? fileOrig,
file: loc?.file ?? sourceFile,
...(loc != null && { line: loc.line }),
...(loc?.column != null && { column: loc.column }),
...(loc?.file != null && loc.file !== fileOrig && { via: fileOrig })
...(loc?.file != null &&
loc.file !== sourceFile && { via: sourceFile })
})
}
}

Loading…
Cancel
Save