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('