| 1 | import type { ResolvedSlidevOptions, SlideInfo } from '@slidev/types' |
| 2 | import type { MarkdownExit } from 'markdown-exit' |
| 3 | import fs from 'node:fs' |
| 4 | import { slash } from '@antfu/utils' |
| 5 | import { yellow } from 'ansis' |
| 6 | import lz from 'lz-string' |
| 7 | import path from 'pathe' |
| 8 | import { isPathInsideRoots } from '../utils' |
| 9 | import { regexSlideSourceId } from '../vite/common' |
| 10 | import { monacoWriterWhitelist } from '../vite/monacoWrite' |
| 11 | |
| 12 | const RE_NEWLINE = /\r?\n/ |
| 13 | |
| 14 | function dedent(text: string): string { |
| 15 | const lines = text.split('\n') |
| 16 | |
| 17 | const minIndentLength = lines.reduce((acc, line) => { |
| 18 | for (let i = 0; i < line.length; i++) { |
| 19 | if (line[i] !== ' ' && line[i] !== '\t') |
| 20 | return Math.min(i, acc) |
| 21 | } |
| 22 | return acc |
| 23 | }, Number.POSITIVE_INFINITY) |
| 24 | |
| 25 | if (minIndentLength < Number.POSITIVE_INFINITY) |
| 26 | return lines.map(x => x.slice(minIndentLength)).join('\n') |
| 27 | |
| 28 | return text |
| 29 | } |
| 30 | |
| 31 | /* eslint-disable regexp/no-super-linear-backtracking */ |
| 32 | const markers = [ |
| 33 | { |
| 34 | start: /^\s*\/\/\s*#?region\b\s*(.*?)\s*$/, |
| 35 | end: /^\s*\/\/\s*#?endregion\b\s*(.*?)\s*$/, |
| 36 | }, |
| 37 | { |
| 38 | start: /^\s*<!--\s*#?region\b\s*(.*?)\s*-->/, |
| 39 | end: /^\s*<!--\s*#?endregion\b\s*(.*?)\s*-->/, |
| 40 | }, |
| 41 | { |
| 42 | start: /^\s*\/\*\s*#region\b\s*(.*?)\s*\*\//, |
| 43 | end: /^\s*\/\*\s*#endregion\b\s*(.*?)\s*\*\//, |
| 44 | }, |
| 45 | { |
| 46 | start: /^\s*#[rR]egion\b\s*(.*?)\s*$/, |
| 47 | end: /^\s*#[eE]nd ?[rR]egion\b\s*(.*?)\s*$/, |
| 48 | }, |
| 49 | { |
| 50 | start: /^\s*#\s*#?region\b\s*(.*?)\s*$/, |
| 51 | end: /^\s*#\s*#?endregion\b\s*(.*?)\s*$/, |
| 52 | }, |
| 53 | { |
| 54 | start: /^\s*(?:--|::|@?REM)\s*#region\b\s*(.*?)\s*$/, |
| 55 | end: /^\s*(?:--|::|@?REM)\s*#endregion\b\s*(.*?)\s*$/, |
| 56 | }, |
| 57 | { |
| 58 | start: /^\s*#pragma\s+region\b\s*(.*?)\s*$/, |
| 59 | end: /^\s*#pragma\s+endregion\b\s*(.*?)\s*$/, |
| 60 | }, |
| 61 | { |
| 62 | start: /^\s*\(\*\s*#region\b\s*(.*?)\s*\*\)/, |
| 63 | end: /^\s*\(\*\s*#endregion\b\s*(.*?)\s*\*\)/, |
| 64 | }, |
| 65 | ] |
| 66 | /* eslint-enable regexp/no-super-linear-backtracking */ |
| 67 | |
| 68 | function findRegion(lines: Array<string>, regionName: string) { |
| 69 | let chosen: { re: (typeof markers)[number], start: number } | null = null |
| 70 | // find the regex pair for a start marker that matches the given region name |
| 71 | for (let i = 0; i < lines.length; i++) { |
| 72 | for (const re of markers) { |
| 73 | if (re.start.exec(lines[i])?.[1] === regionName) { |
| 74 | chosen = { re, start: i + 1 } |
| 75 | break |
| 76 | } |
| 77 | } |
| 78 | if (chosen) |
| 79 | break |
| 80 | } |
| 81 | if (!chosen) |
| 82 | return null |
| 83 | |
| 84 | let counter = 1 |
| 85 | // scan the rest of the lines to find the matching end marker, handling nested markers |
| 86 | for (let i = chosen.start; i < lines.length; i++) { |
| 87 | // check for an inner start marker for the same region |
| 88 | if (chosen.re.start.exec(lines[i])?.[1] === regionName) { |
| 89 | counter++ |
| 90 | continue |
| 91 | } |
| 92 | // check for an end marker for the same region |
| 93 | const endRegion = chosen.re.end.exec(lines[i])?.[1] |
| 94 | // allow empty region name on the end marker as a fallback |
| 95 | if (endRegion === regionName || endRegion === '') { |
| 96 | if (--counter === 0) { |
| 97 | return { |
| 98 | ...chosen, |
| 99 | end: i, |
| 100 | } |
| 101 | } |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | return null |
| 106 | } |
| 107 | |
| 108 | // eslint-disable-next-line regexp/no-super-linear-backtracking |
| 109 | export const RE_SNIPPET_IMPORT = /^<<<[ \t]*(\S.*?)(#[\w-]+)?[ \t]*(?:[ \t](\S+?))?[ \t]*(\{.*)?$/ |
| 110 | |
| 111 | export function resolveSnippetImport(lineText: string, userRoot: string, slide: SlideInfo, allowedRoots: string[] = [userRoot]) { |
| 112 | const match = lineText.trimStart().match(RE_SNIPPET_IMPORT) |
| 113 | if (!match) |
| 114 | return null |
| 115 | |
| 116 | let [, filepath = '', regionName = '', lang = '', meta = ''] = match |
| 117 | const dir = path.dirname(slide.source.filepath) |
| 118 | const src = slash( |
| 119 | filepath.startsWith('@/') |
| 120 | ? path.resolve(userRoot, filepath.slice(2)) |
| 121 | : path.resolve(dir, filepath), |
| 122 | ) |
| 123 | |
| 124 | lang = lang.trim() || path.extname(filepath).slice(1) |
| 125 | meta = meta.trim() |
| 126 | |
| 127 | if (!isPathInsideRoots(src, allowedRoots)) { |
| 128 | throw new Error(`Code snippet path escapes the project root: ${src}`) |
| 129 | } |
| 130 | |
| 131 | const isAFile = fs.existsSync(src) && fs.statSync(src).isFile() |
| 132 | if (!isAFile) { |
| 133 | throw new Error(`Code snippet path not found: ${src}`) |
| 134 | } |
| 135 | |
| 136 | let content = fs.readFileSync(src, 'utf8') |
| 137 | |
| 138 | if (regionName) { |
| 139 | const lines = content.split(RE_NEWLINE) |
| 140 | const region = findRegion(lines, regionName.slice(1)) |
| 141 | if (region) { |
| 142 | content = dedent( |
| 143 | lines |
| 144 | .slice(region.start, region.end) |
| 145 | .filter(l => !(region.re.start.test(l) || region.re.end.test(l))) |
| 146 | .join('\n'), |
| 147 | ) |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | return { content, filepath, lang, meta, src } |
| 152 | } |
| 153 | |
| 154 | export default function MarkdownItSnippet(md: MarkdownExit, { userRoot, userWorkspaceRoot, roots, data: { watchFiles, slides } }: ResolvedSlidevOptions) { |
| 155 | const allowedRoots = [...new Set([userWorkspaceRoot, userRoot, ...(roots ?? [])].filter(Boolean))] |
| 156 | md.block.ruler.before('fence', 'snippet_import', (state, startLine, _endLine, silent) => { |
| 157 | const pos = state.bMarks[startLine] + state.tShift[startLine] |
| 158 | const max = state.eMarks[startLine] |
| 159 | |
| 160 | const lineText = state.src.slice(pos, max) |
| 161 | const match = lineText.match(RE_SNIPPET_IMPORT) |
| 162 | |
| 163 | if (!match) |
| 164 | return false |
| 165 | if (silent) |
| 166 | return true |
| 167 | |
| 168 | const slideNo = state.env.id?.match(regexSlideSourceId) |
| 169 | const slide = slideNo ? slides[slideNo[1] - 1] : null |
| 170 | |
| 171 | if (!slide) { |
| 172 | console.warn(yellow(`[markdown-it-snippet] Snippet syntax is not supported in ${state.env.id || 'unknown source'}. Skipped.`)) |
| 173 | return false |
| 174 | } |
| 175 | |
| 176 | const snippet = resolveSnippetImport(lineText, userRoot, slide, allowedRoots) |
| 177 | if (!snippet) |
| 178 | return false |
| 179 | |
| 180 | const { content, filepath, src } = snippet |
| 181 | let { lang, meta } = snippet |
| 182 | |
| 183 | if (meta.includes('{monaco-write}')) { |
| 184 | monacoWriterWhitelist.add(filepath) |
| 185 | lang = lang.trim() |
| 186 | meta = meta.replace('{monaco-write}', '').trim() || '{}' |
| 187 | const safeFilepath = JSON.stringify(filepath).slice(1, -1) |
| 188 | const encoded = lz.compressToBase64(content) |
| 189 | |
| 190 | const token = state.push('html_block', '', 0) |
| 191 | token.content = `<Monaco writable="${safeFilepath}" code-lz="${encoded}" lang="${lang}" v-bind="${meta}" />\n` |
| 192 | token.map = [startLine, startLine + 1] |
| 193 | } |
| 194 | else { |
| 195 | watchFiles[src] ??= new Set() |
| 196 | watchFiles[src].add(slide.index) |
| 197 | |
| 198 | const token = state.push('fence', 'code', 0) |
| 199 | token.info = `${lang} ${meta}`.trim() |
| 200 | token.content = content.endsWith('\n') ? content : `${content}\n` |
| 201 | token.map = [startLine, startLine + 1] |
| 202 | } |
| 203 | |
| 204 | state.line = startLine + 1 |
| 205 | return true |
| 206 | }, { alt: ['paragraph', 'reference', 'blockquote', 'list'] }) |
| 207 | } |
| 208 |