feat(markdown)!: rebase relative urls in included files

Relative image and link urls inside included markdown files now
resolve from the included file's location instead of from the page
including it, so a partial can link to its neighbors regardless of who
includes it. This matches @mdit/plugin-include, whose resolveImagePath
and resolveLinkPath are on by default as well.

Included content is wrapped in marker comments during expansion;
hidden html_block rules maintain a per-render directory stack and the
image and link_open renderer wrappers rebase '.'-prefixed urls against
its top, so nested includes resolve correctly and urls outside
included content are untouched. The markers are separated by blank
lines so an adjacent html block cannot absorb them, and are only
emitted for directives on a line of their own outside fenced blocks,
so they can never reach the output. The include plugin registers after
the image and link plugins so that rebasing runs before their url
handling.

BREAKING CHANGE: relative urls in included markdown files resolve
against the included file rather than the including page. Partials
written for one specific location may need their links updated, or
markdown.include.rebaseRelativeUrls set to false to keep resolving
them from the including page. Absolute and external urls are
unaffected. Note that the marker comments shift the line numbers
reported for dead links following an include, which already pointed
into the include-expanded source rather than the original file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/5347/head
Divyansh Singh 23 hours ago
parent e4555c9c07
commit ce192cdeb9

@ -445,4 +445,90 @@ describe('node/markdown/plugins/include', () => {
expect(warnings[1]).toContain('nope')
expect(warnings[2]).toContain('b.md')
})
test('keeps relative urls as is with rebaseRelativeUrls: false', async () => {
await write('sub/part.md', '![img](./img.png)\n\n[link](./target.md)\n')
const { html } = await render('<!-- @include: ./sub/part.md -->\n', {
include: { rebaseRelativeUrls: false }
})
expect(html).toContain('src="./img.png"')
expect(html).toContain('href="./target.html"')
expect(html).not.toContain('@include-')
})
test('rebases relative urls inside included files by default', async () => {
await write('sub/part.md', '![img](./img.png)\n\n[link](./target.md)\n')
const { html } = await render(
'<!-- @include: ./sub/part.md -->\n\n[after](./after.md)\n'
)
expect(html).toContain('src="./sub/img.png"')
expect(html).toContain('href="./sub/target.html"')
// links outside the included content are unaffected
expect(html).toContain('href="./after.html"')
// the internal markers never reach the output
expect(html).not.toContain('@include')
})
test('rebases urls through nested includes', async () => {
await write(
'a/one.md',
'one\n\n<!-- @include: ../b/two.md -->\n\n![oneimg](./one.png)\n'
)
await write('b/two.md', '![twoimg](./two.png)\n')
const { html } = await render('<!-- @include: ./a/one.md -->\n')
expect(html).toContain('src="./b/two.png"')
expect(html).toContain('src="./a/one.png"')
})
test('rebases urls after an include ending with an html block', async () => {
await write(
'sub/part.md',
'![inside](./inside.png)\n\n<div class="card">\ntail\n</div>\n'
)
const { html } = await render(
'<!-- @include: ./sub/part.md -->\n\n![after](./after.png)\n\n[after](./after.md)\n'
)
expect(html).toContain('src="./sub/inside.png"')
// the stack must be popped even though the marker follows an html block
expect(html).toContain('src="./after.png"')
expect(html).toContain('href="./after.html"')
expect(html).not.toContain('@include-')
})
test('does not leak rebase markers into fenced includes', async () => {
await write('sub/part.md', 'partial line\n')
const { html } = await render(
'```md\n<!-- @include: ./sub/part.md -->\n```\n'
)
expect(html).toContain('partial line')
expect(html).not.toContain('@include-')
})
test('does not leak rebase markers for inline includes', async () => {
await write('sub/part.md', 'partial line\n')
const { html } = await render(
'before <!-- @include: ./sub/part.md --> after\n\n[link](./x.md)\n'
)
expect(html).toContain('partial line')
expect(html).not.toContain('@include-')
// an inline include leaves the surrounding page urls untouched
expect(html).toContain('href="./x.html"')
})
test('does not rebase absolute or external urls', async () => {
await write(
'sub/part.md',
'[ext](https://example.com/x)\n\n[abs](/abs/target.md)\n'
)
const { html } = await render('<!-- @include: ./sub/part.md -->\n')
expect(html).toContain('href="https://example.com/x"')
expect(html).toContain('href="/abs/target.html"')
})
})

@ -406,9 +406,6 @@ export async function createMarkdownRenderer(
if (options.snippet !== false) {
snippetPlugin(md, srcDir, normalizePluginOptions(options.snippet), logger)
}
if (options.include !== false) {
includePlugin(md, srcDir, normalizePluginOptions(options.include), logger)
}
const containerOptions = normalizePluginOptions(options.container)
if (options.container !== false) {
containerPlugin(md, containerOptions, { locales: options.locales })
@ -425,6 +422,11 @@ export async function createMarkdownRenderer(
base,
slugify
)
// must come after the image and link plugins so that url rebasing runs
// before their href/src handling
if (options.include !== false) {
includePlugin(md, srcDir, normalizePluginOptions(options.include), logger)
}
if (options.tableTabIndex !== false) {
tablePlugin(md)
}

@ -13,12 +13,25 @@ export interface Options {
* @default false
*/
silent?: boolean
/**
* Rewrite relative image and link urls inside included markdown files so
* they resolve from the included file's location instead of the page
* including it.
* @default true
*/
rebaseRelativeUrls?: boolean
}
const includeRE = /<!--\s*@include:\s*(.*?)\s*-->/g
const rangeRE = /\{(\d*),(\d*)\}$/
const regionRE = /#([^\s{]+)$/
const separatorRE = /[\\/]/
const fenceRE = /^ {0,3}(`{3,}|~{3,})/
const rebaseMarkerRE = /^[ \t]*<!-- @include-(?:start: (.*)|end) -->[ \t]*$/gm
// per-render stacks of included-file directories, driven by the rebase
// markers while rendering
const rebaseStacks = new WeakMap<object, string[]>()
/**
* Expands `<!-- @include: path -->` directives before rendering. Wraps
@ -44,6 +57,8 @@ export function includePlugin(
mdEnv!.src = src
return renderAsync(src, env)
}
if (options.rebaseRelativeUrls !== false) registerRebaseRules(md)
}
async function processIncludes(
@ -56,7 +71,9 @@ async function processIncludes(
logger: Pick<Logger, 'warn'>,
ancestors: string[] = []
): Promise<string> {
return replaceAsync(src, includeRE, async (m: string, m1: string) => {
return replaceAsync(src, includeRE, async (...args: string[]) => {
const [m, , rawOffset] = args
let [, m1] = args
if (!m1.length) return m
const fail = (message: string): string => {
@ -148,7 +165,18 @@ async function processIncludes(
[...ancestors, file]
)
return expanded
// 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 &&
path.extname(includePath) === '.md' &&
isOwnLine(src, offset, m.length) &&
!isInsideFence(src, offset)
? `<!-- @include-start: ${path.dirname(includePath)} -->\n\n${expanded}\n\n<!-- @include-end -->`
: expanded
})
}
@ -184,3 +212,69 @@ function findHeadingSection(
return { start: heading.map![1], end }
}
function isOwnLine(src: string, offset: number, length: number) {
const before = src.lastIndexOf('\n', offset - 1) + 1
let after = src.indexOf('\n', offset + length)
if (after === -1) after = src.length
return (
!src.slice(before, offset).trim() &&
!src.slice(offset + length, after).trim()
)
}
function isInsideFence(src: string, offset: number) {
let fence: string | undefined
for (const line of src.slice(0, offset).split('\n')) {
const marker = fenceRE.exec(line)?.[1]
if (!marker) continue
if (fence == null) fence = marker[0]
else if (marker[0] === fence) fence = undefined
}
return fence != null
}
function registerRebaseRules(md: MarkdownItAsync) {
const htmlBlock = md.renderer.rules.html_block!
md.renderer.rules.html_block = (tokens, idx, opts, env, self) => {
const token = tokens[idx]
if (!token.content.includes('<!-- @include-')) {
return htmlBlock(tokens, idx, opts, env, self)
}
// markers are emitted on their own lines, but an adjacent html block can
// still absorb them into its token, so they are matched anywhere in the
// content and removed from it
token.content = token.content.replace(rebaseMarkerRE, (_, dir?: string) => {
let stack = rebaseStacks.get(env)
if (!stack) rebaseStacks.set(env, (stack = []))
if (dir == null) stack.pop()
else stack.push(dir)
return ''
})
return token.content.trim() ? htmlBlock(tokens, idx, opts, env, self) : ''
}
for (const rule of ['image', 'link_open'] as const) {
const render =
md.renderer.rules[rule] ??
((tokens, idx, opts, _env, self) => self.renderToken(tokens, idx, opts))
md.renderer.rules[rule] = (tokens, idx, opts, env, self) => {
const dir = rebaseStacks.get(env)?.at(-1)
const file = (env as MarkdownEnv).path
if (dir && file) {
const token = tokens[idx]
const attr = rule === 'image' ? 'src' : 'href'
const url = token.attrGet(attr)
if (url?.[0] === '.') {
const rebased = slash(
path.join(path.relative(path.dirname(file), dir), url)
)
token.attrSet(attr, rebased[0] === '.' ? rebased : `./${rebased}`)
}
}
return render(tokens, idx, opts, env, self)
}
}
}

Loading…
Cancel
Save