diff --git a/docs/guide/markdown.md b/docs/guide/markdown.md index b79eebd9..fbbaf65f 100644 --- a/docs/guide/markdown.md +++ b/docs/guide/markdown.md @@ -323,6 +323,70 @@ module.exports = { } +## Import Code Snippets + +You can import code snippets from existing files via following syntax: + +```md +<<< @/filepath +``` + +It also supports [line highlighting](#line-highlighting-in-code-blocks): + +```md +<<< @/filepath{highlightLines} +``` + +**Input** + +```md +<<< @/snippets/snippet.js{2} +``` + +**Code file** + + + +<<< @/snippets/snippet.js + + + +**Output** + + + +<<< @/snippets/snippet.js{2} + + + +::: tip +The value of `@` corresponds to `process.cwd()`. +::: + +You can also use a [VS Code region](https://code.visualstudio.com/docs/editor/codebasics#_folding) 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 +<<< @/snippets/snippet-with-region.js{1} +``` + +**Code file** + + + +<<< @/snippets/snippet-with-region.js + + + +**Output** + + + +<<< @/snippets/snippet-with-region.js#snippet{1} + + + ## Advanced Configuration VitePress uses [markdown-it](https://github.com/markdown-it/markdown-it) as the Markdown renderer. A lot of the extensions above are implemented via custom plugins. You can further customize the `markdown-it` instance using the `markdown` option in `.vitepress/config.js`: diff --git a/docs/snippets/snippet-with-region.js b/docs/snippets/snippet-with-region.js new file mode 100644 index 00000000..9c7faaeb --- /dev/null +++ b/docs/snippets/snippet-with-region.js @@ -0,0 +1,7 @@ +// #region snippet +function foo() { + // .. +} +// #endregion snippet + +export default foo diff --git a/docs/snippets/snippet.js b/docs/snippets/snippet.js new file mode 100644 index 00000000..575039d1 --- /dev/null +++ b/docs/snippets/snippet.js @@ -0,0 +1,3 @@ +export default function () { + // .. +} diff --git a/src/node/markdown/plugins/snippet.ts b/src/node/markdown/plugins/snippet.ts index fd56fb56..a2c38ffb 100644 --- a/src/node/markdown/plugins/snippet.ts +++ b/src/node/markdown/plugins/snippet.ts @@ -1,7 +1,83 @@ import fs from 'fs' +import path from 'path' import MarkdownIt from 'markdown-it' import { RuleBlock } from 'markdown-it/lib/parser_block' +function dedent(text: string) { + 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: string, + regexp: RegExp, + regionName: string, + end: boolean = 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: Array, regionName: string) { + 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 +} + export const snippetPlugin = (md: MarkdownIt, root: string) => { const parser: RuleBlock = (state, startLine, endLine, silent) => { const CH = '<'.charCodeAt(0) @@ -24,23 +100,78 @@ export const snippetPlugin = (md: MarkdownIt, root: string) => { 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 content = fs.existsSync(filename) - ? fs.readFileSync(filename).toString() - : 'Not found: ' + filename - const meta = rawPath.replace(filename, '') + + /** + * raw path format: "/path/to/file.extension#region {meta}" + * where #region and {meta} are optional + * + * 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.content = content + token.info = extension + meta + + // @ts-ignore + token.src = path.resolve(filename) + region token.markup = '```' token.map = [startLine, startLine + 1] return true } + const fence = md.renderer.rules.fence! + + md.renderer.rules.fence = (...args) => { + const [tokens, idx, , { loader }] = args + const token = tokens[idx] + // @ts-ignore + const tokenSrc = token.src + const [src, regionName] = tokenSrc ? tokenSrc.split('#') : [''] + + if (src) { + if (loader) { + loader.addDependency(src) + } + 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: string) => !region.regexp.test(line.trim())) + .join('\n') + ) + } + } + + token.content = content + } else { + token.content = isAFile + ? `Code snippet path not found: ${src}` + : `Invalid code snippet option` + token.info = '' + } + } + return fence(...args) + } + md.block.ruler.before('fence', 'snippet', parser) }