From b99d5123c9b2afdc7461089e03476c34d7816faf Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Sun, 9 Mar 2025 12:51:36 +0530 Subject: [PATCH] feat: support using header anchors in markdown file inclusion (#4608) closes #4375 closes #4382 Co-authored-by: btea <2356281422@qq.com> --- .../e2e/markdown-extensions/header-include.md | 27 +++++++++ __tests__/e2e/markdown-extensions/index.md | 6 +- .../markdown-extensions.test.ts | 57 ++++++++++++++++++- docs/en/guide/markdown.md | 47 +++++++++++++++ src/node/markdownToVue.ts | 2 +- src/node/plugins/localSearchPlugin.ts | 2 +- src/node/utils/processIncludes.ts | 52 ++++++++++++++--- 7 files changed, 180 insertions(+), 13 deletions(-) create mode 100644 __tests__/e2e/markdown-extensions/header-include.md diff --git a/__tests__/e2e/markdown-extensions/header-include.md b/__tests__/e2e/markdown-extensions/header-include.md new file mode 100644 index 00000000..70fac23b --- /dev/null +++ b/__tests__/e2e/markdown-extensions/header-include.md @@ -0,0 +1,27 @@ +# header 1 + +header 1 content + +## header 1.1 + +header 1.1 content + +### header 1.1.1 + +header 1.1.1 content + +### header 1.1.2 + +header 1.1.2 content + +## header 1.2 + +header 1.2 content + +### header 1.2.1 + +header 1.2.1 content + +### header 1.2.2 + +header 1.2.2 content diff --git a/__tests__/e2e/markdown-extensions/index.md b/__tests__/e2e/markdown-extensions/index.md index 855803f2..3446b4ef 100644 --- a/__tests__/e2e/markdown-extensions/index.md +++ b/__tests__/e2e/markdown-extensions/index.md @@ -213,6 +213,10 @@ export default config +## Markdown File Inclusion with Header + + + ## Image Lazy Loading -![vitepress logo](/vitepress.png) \ No newline at end of file +![vitepress logo](/vitepress.png) diff --git a/__tests__/e2e/markdown-extensions/markdown-extensions.test.ts b/__tests__/e2e/markdown-extensions/markdown-extensions.test.ts index e49a137f..839f953c 100644 --- a/__tests__/e2e/markdown-extensions/markdown-extensions.test.ts +++ b/__tests__/e2e/markdown-extensions/markdown-extensions.test.ts @@ -64,8 +64,61 @@ describe('Emoji', () => { describe('Table of Contents', () => { test('render toc', async () => { const items = page.locator('#table-of-contents + nav ul li') - const count = await items.count() - expect(count).toBe(44) + expect( + await items.evaluateAll((elements) => + elements.map((el) => el.childNodes[0].textContent) + ) + ).toMatchInlineSnapshot(` + [ + "Links", + "Internal Links", + "External Links", + "GitHub-Style Tables", + "Emoji", + "Table of Contents", + "Custom Containers", + "Default Title", + "Custom Title", + "Line Highlighting in Code Blocks", + "Single Line", + "Multiple single lines, ranges", + "Comment Highlight", + "Line Numbers", + "Import Code Snippets", + "Basic Code Snippet", + "Specify Region", + "With Other Features", + "Code Groups", + "Basic Code Group", + "With Other Features", + "Markdown File Inclusion", + "Region", + "Markdown At File Inclusion", + "Markdown Nested File Inclusion", + "Region", + "After Foo", + "Sub sub", + "Sub sub sub", + "Markdown File Inclusion with Range", + "Region", + "Markdown File Inclusion with Range without Start", + "Region", + "Markdown File Inclusion with Range without End", + "Region", + "Markdown At File Region Snippet", + "Region Snippet", + "Markdown At File Range Region Snippet", + "Range Region Line 2", + "Markdown At File Range Region Snippet without start", + "Range Region Line 1", + "Markdown At File Range Region Snippet without end", + "Range Region Line 3", + "Markdown File Inclusion with Header", + "header 1.1.1", + "header 1.1.2", + "Image Lazy Loading", + ] + `) }) }) diff --git a/docs/en/guide/markdown.md b/docs/en/guide/markdown.md index cf052919..88729f3e 100644 --- a/docs/en/guide/markdown.md +++ b/docs/en/guide/markdown.md @@ -897,6 +897,53 @@ You can also use a [VS Code region](https://code.visualstudio.com/docs/editor/co Note that this does not throw errors if your file is not present. Hence, when using this feature make sure that the contents are being rendered as expected. ::: +Instead of VS Code regions, you can also use header anchors to include a specific section of the file. For example, if you have a header in your markdown file like this: + +```md +## My Base Section + +Some content here. + +### My Sub Section + +Some more content here. + +## Another Section + +Content outside `My Base Section`. +``` + +You can include the `My Base Section` section like this: + +```md +## My Extended Section + +``` + +**Equivalent code** + +```md +## My Extended Section + +Some content here. + +### My Sub Section + +Some more content here. +``` + +Here, `my-base-section` is the generated id of the heading element. In case it's not easily guessable, you can open the part file in your browser and click on the heading anchor (`#` symbol left to the heading when hovered) to see the id in the URL bar. Or use browser dev tools to inspect the element. Alternatively, you can also specify the id to the part file like this: + +```md +## My Base Section {#custom-id} +``` + +and include it like this: + +```md + +``` + ## Math Equations This is currently opt-in. To enable it, you need to install `markdown-it-mathjax3` and set `markdown.math` to `true` in your config file: diff --git a/src/node/markdownToVue.ts b/src/node/markdownToVue.ts index d4576cda..6d2ba98d 100644 --- a/src/node/markdownToVue.ts +++ b/src/node/markdownToVue.ts @@ -142,7 +142,7 @@ export async function createMarkdownToVueRenderFn( // resolve includes let includes: string[] = [] - src = processIncludes(srcDir, src, fileOrig, includes) + src = processIncludes(md, srcDir, src, fileOrig, includes, cleanUrls) const localeIndex = getLocaleForPath(siteConfig?.site, relativePath) diff --git a/src/node/plugins/localSearchPlugin.ts b/src/node/plugins/localSearchPlugin.ts index debc024c..5687ad03 100644 --- a/src/node/plugins/localSearchPlugin.ts +++ b/src/node/plugins/localSearchPlugin.ts @@ -56,7 +56,7 @@ export async function localSearchPlugin( const relativePath = slash(path.relative(srcDir, file)) const env: MarkdownEnv = { path: file, relativePath, cleanUrls } const md_raw = await fs.promises.readFile(file, 'utf-8') - const md_src = processIncludes(srcDir, md_raw, file, []) + const md_src = processIncludes(md, srcDir, md_raw, file, [], cleanUrls) if (options._render) { return await options._render(md_src, env, md) } else { diff --git a/src/node/utils/processIncludes.ts b/src/node/utils/processIncludes.ts index acdccab7..aa98ccbd 100644 --- a/src/node/utils/processIncludes.ts +++ b/src/node/utils/processIncludes.ts @@ -1,18 +1,21 @@ import fs from 'fs-extra' import matter from 'gray-matter' +import type { MarkdownItAsync } from 'markdown-it-async' import path from 'node:path' import c from 'picocolors' import { findRegion } from '../markdown/plugins/snippet' -import { slash } from '../shared' +import { slash, type MarkdownEnv } from '../shared' export function processIncludes( + md: MarkdownItAsync, srcDir: string, src: string, file: string, - includes: string[] + includes: string[], + cleanUrls: boolean ): string { const includesRE = //g - const regionRE = /(#[\w-]+)/ + const regionRE = /(#[^\s\{]+)/ const rangeRE = /\{(\d*),(\d*)\}$/ return src.replace(includesRE, (m: string, m1: string) => { @@ -39,8 +42,34 @@ export function processIncludes( if (region) { const [regionName] = region const lines = content.split(/\r?\n/) - const regionLines = findRegion(lines, regionName.slice(1)) - content = lines.slice(regionLines?.start, regionLines?.end).join('\n') + let { start, end } = findRegion(lines, regionName.slice(1)) ?? {} + + if (start === undefined) { + // region not found, it might be a header + const tokens = md + .parse(content, { + path: includePath, + relativePath: slash(path.relative(srcDir, includePath)), + cleanUrls + } satisfies MarkdownEnv) + .filter((t) => t.type === 'heading_open' && t.map) + const idx = tokens.findIndex( + (t) => t.attrGet('id') === regionName.slice(1) + ) + const token = tokens[idx] + if (token) { + start = token.map![1] + const level = parseInt(token.tag.slice(1)) + for (let i = idx + 1; i < tokens.length; i++) { + if (parseInt(tokens[i].tag.slice(1)) <= level) { + end = tokens[i].map![0] + break + } + } + } + } + + content = lines.slice(start, end).join('\n') } if (range) { @@ -48,8 +77,8 @@ export function processIncludes( const lines = content.split(/\r?\n/) content = lines .slice( - startLine ? parseInt(startLine, 10) - 1 : undefined, - endLine ? parseInt(endLine, 10) : undefined + startLine ? parseInt(startLine) - 1 : undefined, + endLine ? parseInt(endLine) : undefined ) .join('\n') } @@ -60,7 +89,14 @@ export function processIncludes( includes.push(slash(includePath)) // recursively process includes in the content - return processIncludes(srcDir, content, includePath, includes) + return processIncludes( + md, + srcDir, + content, + includePath, + includes, + cleanUrls + ) // } catch (error) {