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) }