From ce192cdeb93620c80d8d372d3b49bde09ed1b54d Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Sun, 2 Aug 2026 05:28:22 +0530 Subject: [PATCH] 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 --- .../node/markdown/plugins/include.test.ts | 86 ++++++++++++++++ src/node/markdown/markdown.ts | 8 +- src/node/markdown/plugins/include.ts | 98 ++++++++++++++++++- 3 files changed, 187 insertions(+), 5 deletions(-) diff --git a/__tests__/unit/node/markdown/plugins/include.test.ts b/__tests__/unit/node/markdown/plugins/include.test.ts index 293c3331..053bd58e 100644 --- a/__tests__/unit/node/markdown/plugins/include.test.ts +++ b/__tests__/unit/node/markdown/plugins/include.test.ts @@ -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('\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( + '\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\n\n![oneimg](./one.png)\n' + ) + await write('b/two.md', '![twoimg](./two.png)\n') + + const { html } = await render('\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
\ntail\n
\n' + ) + + const { html } = await render( + '\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\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 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('\n') + expect(html).toContain('href="https://example.com/x"') + expect(html).toContain('href="/abs/target.html"') + }) }) diff --git a/src/node/markdown/markdown.ts b/src/node/markdown/markdown.ts index 24c225ca..3791465f 100644 --- a/src/node/markdown/markdown.ts +++ b/src/node/markdown/markdown.ts @@ -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) } diff --git a/src/node/markdown/plugins/include.ts b/src/node/markdown/plugins/include.ts index b2f7d144..c2236251 100644 --- a/src/node/markdown/plugins/include.ts +++ b/src/node/markdown/plugins/include.ts @@ -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 = //g 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() /** * Expands `` 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, ancestors: string[] = [] ): Promise { - 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) + ? `\n\n${expanded}\n\n` + : 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('