diff --git a/packages/@vuepress/markdown/__tests__/__snapshots__/snippet.spec.js.snap b/packages/@vuepress/markdown/__tests__/__snapshots__/snippet.spec.js.snap index 6514898292..bae5b09699 100644 --- a/packages/@vuepress/markdown/__tests__/__snapshots__/snippet.spec.js.snap +++ b/packages/@vuepress/markdown/__tests__/__snapshots__/snippet.spec.js.snap @@ -17,6 +17,37 @@ exports[`snippet import snippet 1`] = ` </code></pre> `; +exports[`snippet import snippet with region and highlight 1`] = ` +<pre><code class="language-js{1,3}">function foo () { + return ({ + dest: '../../vuepress', + locales: { + '/': { + lang: 'en-US', + title: 'VuePress', + description: 'Vue-powered Static Site Generator' + }, + '/zh/': { + lang: 'zh-CN', + title: 'VuePress', + description: 'Vue 驱动的静态网站生成器' + } + }, + head: [ + ['link', { rel: 'icon', href: \`/logo.png\` }], + ['link', { rel: 'manifest', href: '/manifest.json' }], + ['meta', { name: 'theme-color', content: '#3eaf7c' }], + ['meta', { name: 'apple-mobile-web-app-capable', content: 'yes' }], + ['meta', { name: 'apple-mobile-web-app-status-bar-style', content: 'black' }], + ['link', { rel: 'apple-touch-icon', href: \`/icons/apple-touch-icon-152x152.png\` }], + ['link', { rel: 'mask-icon', href: '/icons/safari-pinned-tab.svg', color: '#3eaf7c' }], + ['meta', { name: 'msapplication-TileImage', content: '/icons/msapplication-icon-144x144.png' }], + ['meta', { name: 'msapplication-TileColor', content: '#000000' }] + ] + }) +}</code></pre> +`; + exports[`snippet import snippet with highlight multiple lines 1`] = ` <div class="highlight-lines"> <div class="highlighted"> </div> @@ -35,3 +66,72 @@ exports[`snippet import snippet with highlight single line 1`] = ` // .. } `; + +exports[`snippet import snippet with indented region 1`] = ` +<pre><code class="language-html"><section> + <h1>Hello World</h1> +</section> +<div>Lorem Ipsum</div></code></pre> +`; + +exports[`snippet import snippet with region 1`] = ` +<pre><code class="language-js">function foo () { + return ({ + dest: '../../vuepress', + locales: { + '/': { + lang: 'en-US', + title: 'VuePress', + description: 'Vue-powered Static Site Generator' + }, + '/zh/': { + lang: 'zh-CN', + title: 'VuePress', + description: 'Vue 驱动的静态网站生成器' + } + }, + head: [ + ['link', { rel: 'icon', href: \`/logo.png\` }], + ['link', { rel: 'manifest', href: '/manifest.json' }], + ['meta', { name: 'theme-color', content: '#3eaf7c' }], + ['meta', { name: 'apple-mobile-web-app-capable', content: 'yes' }], + ['meta', { name: 'apple-mobile-web-app-status-bar-style', content: 'black' }], + ['link', { rel: 'apple-touch-icon', href: \`/icons/apple-touch-icon-152x152.png\` }], + ['link', { rel: 'mask-icon', href: '/icons/safari-pinned-tab.svg', color: '#3eaf7c' }], + ['meta', { name: 'msapplication-TileImage', content: '/icons/msapplication-icon-144x144.png' }], + ['meta', { name: 'msapplication-TileColor', content: '#000000' }] + ] + }) +}</code></pre> +`; + +exports[`snippet import snippet with region and single line highlight > 10 1`] = ` +<pre><code class="language-js{11}">function foo () { + return ({ + dest: '../../vuepress', + locales: { + '/': { + lang: 'en-US', + title: 'VuePress', + description: 'Vue-powered Static Site Generator' + }, + '/zh/': { + lang: 'zh-CN', + title: 'VuePress', + description: 'Vue 驱动的静态网站生成器' + } + }, + head: [ + ['link', { rel: 'icon', href: \`/logo.png\` }], + ['link', { rel: 'manifest', href: '/manifest.json' }], + ['meta', { name: 'theme-color', content: '#3eaf7c' }], + ['meta', { name: 'apple-mobile-web-app-capable', content: 'yes' }], + ['meta', { name: 'apple-mobile-web-app-status-bar-style', content: 'black' }], + ['link', { rel: 'apple-touch-icon', href: \`/icons/apple-touch-icon-152x152.png\` }], + ['link', { rel: 'mask-icon', href: '/icons/safari-pinned-tab.svg', color: '#3eaf7c' }], + ['meta', { name: 'msapplication-TileImage', content: '/icons/msapplication-icon-144x144.png' }], + ['meta', { name: 'msapplication-TileColor', content: '#000000' }] + ] + }) +}</code></pre> +`; diff --git a/packages/@vuepress/markdown/__tests__/fragments/code-snippet-with-indented-region.md b/packages/@vuepress/markdown/__tests__/fragments/code-snippet-with-indented-region.md new file mode 100644 index 0000000000..71e314122f --- /dev/null +++ b/packages/@vuepress/markdown/__tests__/fragments/code-snippet-with-indented-region.md @@ -0,0 +1 @@ +<<< @/packages/@vuepress/markdown/__tests__/fragments/snippet-with-indented-region.html#body diff --git a/packages/@vuepress/markdown/__tests__/fragments/code-snippet-with-region-and-highlight.md b/packages/@vuepress/markdown/__tests__/fragments/code-snippet-with-region-and-highlight.md new file mode 100644 index 0000000000..45f13d6f05 --- /dev/null +++ b/packages/@vuepress/markdown/__tests__/fragments/code-snippet-with-region-and-highlight.md @@ -0,0 +1 @@ +<<< @/packages/@vuepress/markdown/__tests__/fragments/snippet-with-region.js#snippet{1,3} diff --git a/packages/@vuepress/markdown/__tests__/fragments/code-snippet-with-region-and-single-highlight.md b/packages/@vuepress/markdown/__tests__/fragments/code-snippet-with-region-and-single-highlight.md new file mode 100644 index 0000000000..c67cff011a --- /dev/null +++ b/packages/@vuepress/markdown/__tests__/fragments/code-snippet-with-region-and-single-highlight.md @@ -0,0 +1 @@ +<<< @/packages/@vuepress/markdown/__tests__/fragments/snippet-with-region.js#snippet{11} diff --git a/packages/@vuepress/markdown/__tests__/fragments/code-snippet-with-region.md b/packages/@vuepress/markdown/__tests__/fragments/code-snippet-with-region.md new file mode 100644 index 0000000000..a335dbec46 --- /dev/null +++ b/packages/@vuepress/markdown/__tests__/fragments/code-snippet-with-region.md @@ -0,0 +1 @@ +<<< @/packages/@vuepress/markdown/__tests__/fragments/snippet-with-region.js#snippet diff --git a/packages/@vuepress/markdown/__tests__/fragments/snippet-with-indented-region.html b/packages/@vuepress/markdown/__tests__/fragments/snippet-with-indented-region.html new file mode 100644 index 0000000000..ff845c8ac5 --- /dev/null +++ b/packages/@vuepress/markdown/__tests__/fragments/snippet-with-indented-region.html @@ -0,0 +1,16 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Document</title> +</head> +<body> + <!-- #region body --> + <section> + <h1>Hello World</h1> + </section> + <div>Lorem Ipsum</div> + <!-- #endregion body --> +</body> +</html> \ No newline at end of file diff --git a/packages/@vuepress/markdown/__tests__/fragments/snippet-with-region.js b/packages/@vuepress/markdown/__tests__/fragments/snippet-with-region.js new file mode 100644 index 0000000000..bacd171ddd --- /dev/null +++ b/packages/@vuepress/markdown/__tests__/fragments/snippet-with-region.js @@ -0,0 +1,32 @@ +// #region snippet +function foo () { + return ({ + dest: '../../vuepress', + locales: { + '/': { + lang: 'en-US', + title: 'VuePress', + description: 'Vue-powered Static Site Generator' + }, + '/zh/': { + lang: 'zh-CN', + title: 'VuePress', + description: 'Vue 驱动的静态网站生成器' + } + }, + head: [ + ['link', { rel: 'icon', href: `/logo.png` }], + ['link', { rel: 'manifest', href: '/manifest.json' }], + ['meta', { name: 'theme-color', content: '#3eaf7c' }], + ['meta', { name: 'apple-mobile-web-app-capable', content: 'yes' }], + ['meta', { name: 'apple-mobile-web-app-status-bar-style', content: 'black' }], + ['link', { rel: 'apple-touch-icon', href: `/icons/apple-touch-icon-152x152.png` }], + ['link', { rel: 'mask-icon', href: '/icons/safari-pinned-tab.svg', color: '#3eaf7c' }], + ['meta', { name: 'msapplication-TileImage', content: '/icons/msapplication-icon-144x144.png' }], + ['meta', { name: 'msapplication-TileColor', content: '#000000' }] + ] + }) +} +// #endregion snippet + +export default foo diff --git a/packages/@vuepress/markdown/__tests__/snippet.spec.js b/packages/@vuepress/markdown/__tests__/snippet.spec.js index e8e25ea723..af018be673 100644 --- a/packages/@vuepress/markdown/__tests__/snippet.spec.js +++ b/packages/@vuepress/markdown/__tests__/snippet.spec.js @@ -30,4 +30,28 @@ describe('snippet', () => { const output = mdH.render(input) expect(output).toMatchSnapshot() }) + + test('import snippet with region', () => { + const input = getFragment(__dirname, 'code-snippet-with-region.md') + const output = md.render(input) + expect(output).toMatchSnapshot() + }) + + test('import snippet with region and highlight', () => { + const input = getFragment(__dirname, 'code-snippet-with-region-and-highlight.md') + const output = md.render(input) + expect(output).toMatchSnapshot() + }) + + test('import snippet with region and single line highlight > 10', () => { + const input = getFragment(__dirname, 'code-snippet-with-region-and-single-highlight.md') + const output = md.render(input) + expect(output).toMatchSnapshot() + }) + + test('import snippet with indented region', () => { + const input = getFragment(__dirname, 'code-snippet-with-indented-region.md') + const output = md.render(input) + expect(output).toMatchSnapshot() + }) }) diff --git a/packages/@vuepress/markdown/lib/snippet.js b/packages/@vuepress/markdown/lib/snippet.js index 9ee4e726f3..037498d336 100644 --- a/packages/@vuepress/markdown/lib/snippet.js +++ b/packages/@vuepress/markdown/lib/snippet.js @@ -1,5 +1,74 @@ const { fs, logger, path } = require('@vuepress/shared-utils') +function dedent (text) { + const wRegexp = /^([ \t]*)(.*)\n/gm + let match; let minIndentLength = null + + while ((match = wRegexp.exec(text)) !== null) { + const [indentation, content] = match.slice(1) + if (!content) continue + + const indentLength = indentation.length + if (indentLength > 0) { + minIndentLength + = minIndentLength !== null + ? Math.min(minIndentLength, indentLength) + : indentLength + } else break + } + + if (minIndentLength) { + text = text.replace( + new RegExp(`^[ \t]{${minIndentLength}}(.*)`, 'gm'), + '$1' + ) + } + + return text +} + +function testLine (line, regexp, regionName, end = false) { + const [full, tag, name] = regexp.exec(line.trim()) || [] + + return ( + full + && tag + && name === regionName + && tag.match(end ? /^[Ee]nd ?[rR]egion$/ : /^[rR]egion$/) + ) +} + +function findRegion (lines, regionName) { + const regionRegexps = [ + /^\/\/ ?#?((?:end)?region) ([\w*-]+)$/, // javascript, typescript, java + /^\/\* ?#((?:end)?region) ([\w*-]+) ?\*\/$/, // css, less, scss + /^#pragma ((?:end)?region) ([\w*-]+)$/, // C, C++ + /^<!-- #?((?:end)?region) ([\w*-]+) -->$/, // HTML, markdown + /^#((?:End )Region) ([\w*-]+)$/, // Visual Basic + /^::#((?:end)region) ([\w*-]+)$/, // Bat + /^# ?((?:end)?region) ([\w*-]+)$/ // C#, PHP, Powershell, Python, perl & misc + ] + + let regexp = null + let start = -1 + + for (const [lineId, line] of lines.entries()) { + if (regexp === null) { + for (const reg of regionRegexps) { + if (testLine(line, reg, regionName)) { + start = lineId + 1 + regexp = reg + break + } + } + } else if (testLine(line, regexp, regionName, true)) { + return { start, end: lineId, regexp } + } + } + + return null +} + module.exports = function snippet (md, options = {}) { const fence = md.renderer.rules.fence const root = options.root || process.cwd() @@ -7,15 +76,32 @@ module.exports = function snippet (md, options = {}) { md.renderer.rules.fence = (...args) => { const [tokens, idx, , { loader }] = args const token = tokens[idx] - const { src } = token + const [src, regionName] = token.src ? token.src.split('#') : [''] if (src) { if (loader) { loader.addDependency(src) } - if (fs.existsSync(src)) { - token.content = fs.readFileSync(src, 'utf8') + const isAFile = fs.lstatSync(src).isFile() + if (fs.existsSync(src) && isAFile) { + let content = fs.readFileSync(src, 'utf8') + + if (regionName) { + const lines = content.split(/\r?\n/) + const region = findRegion(lines, regionName) + + if (region) { + content = dedent( + lines + .slice(region.start, region.end) + .filter(line => !region.regexp.test(line.trim())) + .join('\n') + ) + } + } + + token.content = content } else { - token.content = `Code snippet path not found: ${src}` + token.content = isAFile ? `Code snippet path not found: ${src}` : `Invalid code snippet option` token.info = '' logger.error(token.content) } @@ -44,15 +130,23 @@ module.exports = function snippet (md, options = {}) { const start = pos + 3 const end = state.skipSpacesBack(max, pos) - const rawPath = state.src.slice(start, end).trim().replace(/^@/, root) - const filename = rawPath.split(/{/).shift().trim() - const meta = rawPath.replace(filename, '') + + /** + * raw path format: "/path/to/file.extension#region {meta}" + * where #region and {meta} are optionnal + * + * captures: ['/path/to/file.extension', 'extension', '#region', '{meta}'] + */ + const rawPathRegexp = /^(.+(?:\.([a-z]+)))(?:(#[\w-]+))?(?: ?({\d+(?:[,-]\d+)?}))?$/ + + const rawPath = state.src.slice(start, end).trim().replace(/^@/, root).trim() + const [filename = '', extension = '', region = '', meta = ''] = (rawPathRegexp.exec(rawPath) || []).slice(1) state.line = startLine + 1 const token = state.push('fence', 'code', 0) - token.info = filename.split('.').pop() + meta - token.src = path.resolve(filename) + token.info = extension + meta + token.src = path.resolve(filename) + region token.markup = '```' token.map = [startLine, startLine + 1] diff --git a/packages/docs/docs/guide/markdown.md b/packages/docs/docs/guide/markdown.md index af2a7f13bc..41403951aa 100644 --- a/packages/docs/docs/guide/markdown.md +++ b/packages/docs/docs/guide/markdown.md @@ -345,6 +345,29 @@ It also supports [line highlighting](#line-highlighting-in-code-blocks): Since the import of the code snippets will be executed before webpack compilation, you can’t use the path alias in webpack. The default value of `@` is `process.cwd()`. ::: +You can also use a [VS Code region](https://code.visualstudio.com/docs/editor/codebasics#_folding) in order to only include the corresponding part of the code file. You can provide a custom region name after a `#` following the filepath (`snippet` by default). + +**Input** + +``` md +<<< @/../@vuepress/markdown/__tests__/fragments/snippet-with-region.js#snippet{1} +``` + +**Code file** + +<!--lint disable strong-marker--> + +<<< @/../@vuepress/markdown/__tests__/fragments/snippet-with-region.js + +<!--lint enable strong-marker--> + +**Output** + +<!--lint disable strong-marker--> + +<<< @/../@vuepress/markdown/__tests__/fragments/snippet-with-region.js#snippet{1} + +<!--lint enable strong-marker--> ## Advanced Configuration