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`] = ` `; +exports[`snippet import snippet with region and highlight 1`] = ` +
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' }]
+    ]
+  })
+}
+`; + exports[`snippet import snippet with highlight multiple lines 1`] = `
 
@@ -35,3 +66,72 @@ exports[`snippet import snippet with highlight single line 1`] = ` // .. } `; + +exports[`snippet import snippet with indented region 1`] = ` +
<section>
+  <h1>Hello World</h1>
+</section>
+<div>Lorem Ipsum</div>
+`; + +exports[`snippet import snippet with region 1`] = ` +
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' }]
+    ]
+  })
+}
+`; + +exports[`snippet import snippet with region and single line highlight > 10 1`] = ` +
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' }]
+    ]
+  })
+}
+`; 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 @@ + + + + + + Document + + + +
+

Hello World

+
+
Lorem Ipsum
+ + + \ 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++ + /^$/, // 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** + + + +<<< @/../@vuepress/markdown/__tests__/fragments/snippet-with-region.js + + + +**Output** + + + +<<< @/../@vuepress/markdown/__tests__/fragments/snippet-with-region.js#snippet{1} + + ## Advanced Configuration